Merge origin/garnet-hemisphere (post-9e196d34) — Use Plugin handoff fix

Brings in 11 new garnet commits, most importantly:
- 1a90aef4 feat(plugin-use): implement plugin use handoff functionality —
  fixes the bug QA reported where /plugins Use Plugin would 422 silently
  for template plugins; new flow hands off to HomeView with the plugin
  pre-bound + input form prompted there.
- 2ac58544 feat(plugin-inputs): enhance plugin input handling with file
  upload support — extends PluginInputsForm for file uploads.
- 3b167b69 feat(plugins): registry protocol — new @open-design/registry-protocol
  workspace package (needs build before daemon boot).
- Plus enhancements to plugin metadata, GitHub installer, plugin detail
  view, login/whoami, static HTML preview paths.

Conflicts resolved:
- packages/contracts/src/api/projects.ts: HEAD's skipDiscoveryBrief
  field + garnet's contextPlugins (@-mention plugin context refs) both
  kept on ProjectMetadata.
- apps/landing-page/* (3 files): accepted HEAD — garnet had the older
  single-page landing-page header; main has the multi-page layout
  (/skills/, /systems/, /templates/, /craft/) with dynamic counts. Not
  related to the Use Plugin core fix.

New @open-design/registry-protocol package must be built before daemon
boots; pnpm install does this via postinstall already.
This commit is contained in:
lefarcen 2026-05-14 16:32:35 +08:00
commit b268bbe169
103 changed files with 19849 additions and 450 deletions

View file

@ -29,7 +29,7 @@
"dev": "pnpm run build && node dist/cli.js --no-open",
"start": "pnpm run build && node dist/cli.js",
"test": "vitest run -c vitest.config.ts",
"typecheck": "pnpm --filter @open-design/contracts build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
"typecheck": "pnpm --filter @open-design/contracts build && pnpm --filter @open-design/registry-protocol build && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
@ -37,6 +37,7 @@
"@open-design/contracts": "workspace:*",
"@open-design/platform": "workspace:*",
"@open-design/plugin-runtime": "workspace:*",
"@open-design/registry-protocol": "workspace:*",
"@open-design/sidecar": "workspace:*",
"@open-design/sidecar-proto": "workspace:*",
"@opentelemetry/api": "^1.9.0",

View file

@ -87,6 +87,11 @@ const PLUGIN_STRING_FLAGS = new Set([
'before',
'trust',
'tag',
'policy',
'version',
'reason',
'catalog',
'host',
]);
const PLUGIN_BOOLEAN_FLAGS = new Set([
'help',
@ -94,6 +99,7 @@ const PLUGIN_BOOLEAN_FLAGS = new Set([
'json',
'revoke',
'follow',
'strict',
]);
const UI_STRING_FLAGS = new Set([
@ -891,8 +897,11 @@ async function runPlugin(args) {
case 'scaffold': return runPluginScaffold(rest);
case 'validate': return runPluginValidate(rest);
case 'pack': return runPluginPack(rest);
case 'login': return runPluginLogin(rest);
case 'whoami': return runPluginWhoami(rest);
case 'export': return runPluginExport(rest);
case 'publish': return runPluginPublish(rest);
case 'yank': return runPluginYank(rest);
default:
console.error(`unknown subcommand: od plugin ${sub}`);
printPluginHelp();
@ -1127,6 +1136,119 @@ Exit codes:
}
}
async function runPluginLogin(rest) {
const flags = parseFlags(rest, {
string: new Set(['host']),
boolean: new Set(['help', 'h']),
});
if (flags.help || flags.h) {
console.log(`Usage:
od plugin login [--host github.com]
Wraps GitHub CLI auth for Open Design registry publishing. The token stays in gh.`);
return;
}
const host = typeof flags.host === 'string' ? flags.host : 'github.com';
const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 });
if (!version.ok) {
console.error('[plugin login] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.');
process.exit(1);
}
const result = await spawnPassthrough('gh', ['auth', 'login', '--hostname', host, '--web']);
process.exit(result.code ?? 0);
}
async function runPluginWhoami(rest) {
const flags = parseFlags(rest, {
string: new Set(['host']),
boolean: new Set(['help', 'h', 'json']),
});
if (flags.help || flags.h) {
console.log(`Usage:
od plugin whoami [--host github.com] [--json]
Shows the GitHub account gh will use for Open Design registry publishing.`);
return;
}
const host = typeof flags.host === 'string' ? flags.host : 'github.com';
const auth = await execFileBuffered('gh', ['auth', 'status', '--hostname', host], { timeout: 10_000 });
if (!auth.ok) {
if (flags.json) {
process.stdout.write(JSON.stringify({
ok: false,
host,
message: 'GitHub CLI is not authenticated for this host.',
log: auth.stderr || auth.stdout,
}, null, 2) + '\n');
return;
}
console.error(`[plugin whoami] gh is not authenticated for ${host}. Run: od plugin login --host ${host}`);
if (auth.stderr || auth.stdout) console.error(auth.stderr || auth.stdout);
process.exit(1);
}
const user = await execFileBuffered('gh', ['api', 'user', '--hostname', host], { timeout: 10_000 });
let login = '';
let name = '';
try {
const parsed = JSON.parse(user.stdout || '{}');
login = typeof parsed.login === 'string' ? parsed.login : '';
name = typeof parsed.name === 'string' ? parsed.name : '';
} catch {
// Keep the auth status useful even if gh api output is unavailable.
}
const payload = {
ok: true,
host,
login,
name,
auth: auth.stderr || auth.stdout,
};
if (flags.json) {
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
} else {
console.log(`[plugin whoami] ${login || 'authenticated'}${name ? ` (${name})` : ''} @ ${host}`);
}
}
async function execFileBuffered(command, args, opts = {}) {
const { execFile } = await import('node:child_process');
return new Promise((resolve) => {
execFile(command, args, {
timeout: 30_000,
maxBuffer: 1024 * 1024,
...opts,
}, (error, stdout, stderr) => {
resolve({
ok: !error,
code: error?.code,
stdout: String(stdout ?? '').trim(),
stderr: String(stderr ?? '').trim(),
error,
});
});
});
}
async function spawnPassthrough(command, args) {
const { spawn } = await import('node:child_process');
return await new Promise((resolve) => {
const child = spawn(command, args, { stdio: 'inherit' });
child.on('error', (error) => resolve({ code: 1, error }));
child.on('close', (code) => resolve({ code }));
});
}
function inferGithubHost(target) {
if (!target || target === 'github.com') return 'github.com';
try {
const parsed = new URL(target);
return parsed.hostname || 'github.com';
} catch {
// Marketplace ids are not URLs; v1 GitHub-backed auth defaults to github.com.
return 'github.com';
}
}
// Phase 4 / spec §14 — `od plugin export <projectId> --as <target>`.
//
// Produces a publish-ready folder from the AppliedPluginSnapshot
@ -1191,6 +1313,10 @@ async function runMarketplace(args) {
od marketplace add <url> [--trust trusted|restricted] Register a federated catalog.
od marketplace list List registered marketplaces.
od marketplace info <id> Inspect one marketplace + cached manifest.
od marketplace plugins <id> [--json] List cached plugin entries for one marketplace.
od marketplace search <query> [--json] Search cached marketplace entries.
od marketplace doctor [id] [--strict] [--json] Validate cached marketplace entries.
od marketplace login <id|url> [--host github.com] Authenticate gh for private GitHub catalogs.
od marketplace refresh <id> Re-fetch the manifest.
od marketplace remove <id> Forget a marketplace.
od marketplace trust <id> [--trust trusted|restricted|official]
@ -1273,6 +1399,81 @@ Common options:
}
return;
}
case 'plugins': {
const id = rest.find((a) => !a.startsWith('-'));
if (!id) {
console.error('Usage: od marketplace plugins <id> [--json]');
process.exit(2);
}
const resp = await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}/plugins`);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error(`plugins failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
const plugins = Array.isArray(data?.plugins) ? data.plugins : [];
if (flags.json) {
process.stdout.write(JSON.stringify({ marketplaceId: id, plugins }, null, 2) + '\n');
return;
}
if (plugins.length === 0) {
console.log(`No plugins in marketplace ${id}.`);
return;
}
for (const p of plugins) {
console.log(`${p.name}@${p.version}\t${p.source}\t${p.description ?? ''}`);
}
return;
}
case 'doctor': {
const strict = flags.strict === true;
const id = rest.find((a) => !a.startsWith('-'));
const resp = id
? await fetch(`${base}/api/marketplaces/${encodeURIComponent(id)}`)
: await fetch(`${base}/api/marketplaces`);
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error(`doctor failed: ${resp.status} ${JSON.stringify(data)}`);
process.exit(1);
}
const rows = id ? [data] : (data?.marketplaces ?? []);
const { doctorMarketplace } = await import('./plugins/marketplace-doctor.js');
const reports = [];
for (const row of rows) {
reports.push(await doctorMarketplace({
id: row.id,
trust: row.trust,
manifest: row.manifest,
strict,
}));
}
const ok = reports.every((report) => report.ok);
if (flags.json) {
process.stdout.write(JSON.stringify({ ok, reports }, null, 2) + '\n');
} else {
for (const report of reports) {
console.log(`[marketplace doctor] ${report.backendId}: ${report.ok ? 'ok' : 'issues'} (${report.entriesChecked} entries)`);
for (const issue of report.issues) {
console.log(` [${issue.severity}] ${issue.code}${issue.pluginName ? ` ${issue.pluginName}` : ''}: ${issue.message}`);
}
}
}
process.exit(ok ? 0 : 1);
}
case 'login': {
const target = rest.find((a) => !a.startsWith('-'));
const host = typeof flags.host === 'string'
? flags.host
: inferGithubHost(target ?? 'github.com');
const version = await execFileBuffered('gh', ['--version'], { timeout: 10_000 });
if (!version.ok) {
console.error('[marketplace login] GitHub CLI is required. Install gh from https://cli.github.com/ and retry.');
process.exit(1);
}
console.log(`[marketplace login] authenticating gh for ${host}. Tokens stay in gh, not Open Design.`);
const result = await spawnPassthrough('gh', ['auth', 'login', '--hostname', host, '--web']);
process.exit(result.code ?? 0);
}
case 'add': {
const url = rest.find((a) => !a.startsWith('-'));
if (!url) {
@ -1732,13 +1933,34 @@ function emitPluginList({ entries, json, emptyMessage, showRank }) {
async function runPluginInfo(rest) {
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const id = rest.find((a) => !a.startsWith('--') && a !== flags['daemon-url'] && a !== flags.source);
const id = rest.find((a) => !a.startsWith('--')
&& a !== flags['daemon-url']
&& a !== flags.source
&& a !== flags.version);
if (!id) {
console.error('Usage: od plugin info <id>');
console.error('Usage: od plugin info <id-or-marketplace-name> [--version <version|tag|range>] [--json]');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}`;
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
const url = `${base}/api/plugins/${encodeURIComponent(id)}`;
const resp = await fetch(url);
if (resp.ok && !flags.version) {
const data = await resp.json();
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
return;
}
const mpResp = await fetch(`${base}/api/marketplaces`);
if (mpResp.ok) {
const mpData = await mpResp.json().catch(() => ({}));
const resolved = resolveMarketplacePluginFromList(
mpData?.marketplaces ?? [],
flags.version ? `${id}@${flags.version}` : id,
);
if (resolved) {
process.stdout.write(JSON.stringify({ marketplace: resolved }, null, 2) + '\n');
return;
}
}
if (!resp.ok) {
console.error(`GET /api/plugins/${id} failed: ${resp.status} ${await resp.text()}`);
process.exit(1);
@ -1747,6 +1969,57 @@ async function runPluginInfo(rest) {
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
}
function resolveMarketplacePluginFromList(marketplaces, specifier) {
const parsed = parseCliPluginSpecifier(specifier);
const target = parsed.name.toLowerCase();
for (const marketplace of marketplaces) {
for (const entry of marketplace?.manifest?.plugins ?? []) {
if (String(entry.name ?? '').toLowerCase() !== target) continue;
const version = resolveCliEntryVersion(entry, parsed.range);
if (!version) return null;
return {
marketplaceId: marketplace.id,
marketplaceTrust: marketplace.trust,
name: entry.name,
version: version.version,
source: version.source,
ref: version.ref,
integrity: version.integrity,
manifestDigest: version.manifestDigest,
entry,
};
}
}
return null;
}
function parseCliPluginSpecifier(input) {
const trimmed = String(input ?? '').trim();
const slash = trimmed.indexOf('/');
const at = trimmed.lastIndexOf('@');
if (slash > 0 && at > slash + 1) {
return { name: trimmed.slice(0, at), range: trimmed.slice(at + 1) };
}
return { name: trimmed, range: undefined };
}
function resolveCliEntryVersion(entry, range) {
if (entry?.yanked) return null;
const versions = Array.isArray(entry?.versions) ? entry.versions : [];
const target = range && range !== 'latest'
? (entry?.distTags?.[range] ?? range)
: (entry?.distTags?.latest ?? entry?.version);
const version = versions.find((item) => item.version === target) ?? null;
if (version?.yanked) return null;
return {
version: target,
source: version?.source ?? entry?.source,
ref: version?.ref ?? entry?.ref,
integrity: version?.integrity ?? version?.dist?.integrity ?? entry?.integrity ?? entry?.dist?.integrity,
manifestDigest: version?.manifestDigest ?? version?.dist?.manifestDigest ?? entry?.manifestDigest ?? entry?.dist?.manifestDigest,
};
}
// Plan §3.MM1 — `od plugin manifest <id>`. Prints just the parsed
// manifest JSON, no wrapper. Useful for plugin authors who want to
// compare the daemon's view to their on-disk open-design.json
@ -1830,7 +2103,7 @@ async function runPluginInstall(rest) {
' od plugin install ./local-folder\n' +
' od plugin install github:owner/repo[@ref][/subpath]\n' +
' od plugin install https://example.com/plugin.tar.gz\n' +
' od plugin install <name> # resolves through configured marketplaces');
' od plugin install <name>[@version|tag|range] # resolves through configured marketplaces');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/install`;
@ -1847,6 +2120,8 @@ async function runPluginInstall(rest) {
const decoder = new TextDecoder();
let buffer = '';
let exitCode = 0;
const events = [];
let finalEvent = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
@ -1859,19 +2134,29 @@ async function runPluginInstall(rest) {
const dataLine = lines.find((l) => l.startsWith('data: '));
const event = eventLine ? eventLine.slice('event: '.length) : 'message';
const data = dataLine ? safeParseJson(dataLine.slice('data: '.length)) : null;
events.push({ event, data });
if (event === 'progress') {
console.log(`[install] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
if (!flags.json) console.log(`[install] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
} else if (event === 'success') {
console.log(`[install] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (Array.isArray(data?.warnings) && data.warnings.length > 0) {
finalEvent = data;
if (!flags.json) console.log(`[install] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) {
for (const w of data.warnings) console.log(`[install] warn: ${w}`);
}
} else if (event === 'error') {
console.error(`[install] error: ${data?.message ?? 'unknown'}`);
finalEvent = data;
if (!flags.json) console.error(`[install] error: ${data?.message ?? 'unknown'}`);
exitCode = 1;
}
}
}
if (flags.json) {
process.stdout.write(JSON.stringify({
ok: exitCode === 0,
result: finalEvent,
events,
}, null, 2) + '\n');
}
process.exit(exitCode);
}
@ -2504,13 +2789,16 @@ async function runPluginUpgrade(rest) {
const flags = parseFlags(rest, { string: PLUGIN_STRING_FLAGS, boolean: PLUGIN_BOOLEAN_FLAGS });
const id = rest.find((a) => !a.startsWith('-') && a !== flags['daemon-url'] && a !== flags.source);
if (!id) {
console.error('Usage: od plugin upgrade <id>');
console.error('Usage: od plugin upgrade <id> [--policy latest|pinned] [--json]');
process.exit(2);
}
const url = `${pluginDaemonUrl(flags).replace(/\/$/, '')}/api/plugins/${encodeURIComponent(id)}/upgrade`;
const resp = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'text/event-stream' },
body: JSON.stringify({
policy: flags.policy === 'pinned' ? 'pinned' : 'latest',
}),
});
if (!resp.ok || !resp.body) {
let msg = '';
@ -2522,6 +2810,8 @@ async function runPluginUpgrade(rest) {
const decoder = new TextDecoder();
let buffer = '';
let exitCode = 0;
const events = [];
let finalEvent = null;
while (true) {
const { value, done } = await reader.read();
if (done) break;
@ -2534,19 +2824,30 @@ async function runPluginUpgrade(rest) {
const dataLine = lines.find((l) => l.startsWith('data: '));
const event = eventLine ? eventLine.slice('event: '.length) : 'message';
const data = dataLine ? safeParseJson(dataLine.slice('data: '.length)) : null;
events.push({ event, data });
if (event === 'progress') {
console.log(`[upgrade] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
if (!flags.json) console.log(`[upgrade] ${data?.phase ?? '...'}: ${data?.message ?? ''}`);
} else if (event === 'success') {
console.log(`[upgrade] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (Array.isArray(data?.warnings) && data.warnings.length > 0) {
finalEvent = data;
if (!flags.json) console.log(`[upgrade] ok — ${data?.plugin?.id}@${data?.plugin?.version} (trust=${data?.plugin?.trust})`);
if (!flags.json && Array.isArray(data?.warnings) && data.warnings.length > 0) {
for (const w of data.warnings) console.log(`[upgrade] warn: ${w}`);
}
} else if (event === 'error') {
console.error(`[upgrade] error: ${data?.message ?? 'unknown'}`);
finalEvent = data;
if (!flags.json) console.error(`[upgrade] error: ${data?.message ?? 'unknown'}`);
exitCode = 1;
}
}
}
if (flags.json) {
process.stdout.write(JSON.stringify({
ok: exitCode === 0,
policy: flags.policy === 'pinned' ? 'pinned' : 'latest',
result: finalEvent,
events,
}, null, 2) + '\n');
}
process.exit(exitCode);
}
@ -2662,13 +2963,14 @@ function coerceCliValue(raw) {
// always under the author's control.
async function runPluginPublish(rest) {
const flags = parseFlags(rest, {
string: new Set(['daemon-url', 'to', 'snapshot-id', 'repo']),
string: new Set(['daemon-url', 'to', 'snapshot-id', 'repo', 'catalog']),
boolean: new Set(['help', 'h', 'json', 'open']),
});
if (rest.length === 0 || flags.help || flags.h) {
console.log(`Usage:
od plugin publish <pluginId> --to anthropics-skills|awesome-agent-skills|clawhub|skills-sh
od plugin publish <pluginId> --to open-design|anthropics-skills|awesome-agent-skills|clawhub|skills-sh
[--repo <github-url>] [--snapshot-id <id>] [--open] [--json]
od plugin publish <pluginId> --to marketplace-json --catalog ./open-design-marketplace.json --repo <github-url>
The CLI prints the catalog's submission URL + a pre-filled PR body.
Pass --open to auto-launch the system browser. Use --snapshot-id to
@ -2685,7 +2987,7 @@ publish from a frozen run snapshot rather than the live installed copy.`);
process.exit(2);
}
if (!target) {
console.error('--to <catalog> is required (one of: anthropics-skills, awesome-agent-skills, clawhub, skills-sh)');
console.error('--to <catalog> is required (one of: open-design, anthropics-skills, awesome-agent-skills, clawhub, skills-sh)');
process.exit(2);
}
const base = pluginDaemonUrl(flags).replace(/\/$/, '');
@ -2712,6 +3014,27 @@ publish from a frozen run snapshot rather than the live installed copy.`);
if (typeof flags.repo === 'string' && flags.repo.length > 0) {
meta.repoUrl = flags.repo;
}
if (target === 'marketplace-json') {
if (typeof flags.catalog !== 'string' || flags.catalog.length === 0) {
console.error('--catalog <path> is required for --to marketplace-json');
process.exit(2);
}
if (!meta.repoUrl) {
console.error('--repo <github-url> is required for --to marketplace-json so the source can be reproduced');
process.exit(2);
}
const outcome = await publishToMarketplaceJson({
catalogPath: flags.catalog,
meta,
});
if (flags.json) {
process.stdout.write(JSON.stringify(outcome, null, 2) + '\n');
} else {
console.log(`[publish] updated ${outcome.catalogPath}`);
console.log(`[publish] ${outcome.entry.name}@${outcome.entry.version} -> ${outcome.entry.source}`);
}
return;
}
const { buildPublishLink, PublishError } = await import('./plugins/publish.js');
let link;
try {
@ -2740,6 +3063,118 @@ publish from a frozen run snapshot rather than the live installed copy.`);
}
}
async function publishToMarketplaceJson({ catalogPath, meta }) {
const [{ dirname, resolve }, { mkdir, readFile, writeFile }, { PublishError, upsertMarketplaceJsonEntry }] = await Promise.all([
import('node:path'),
import('node:fs/promises'),
import('./plugins/publish.js'),
]);
const resolvedPath = resolve(process.cwd(), catalogPath);
let existing = null;
try {
existing = JSON.parse(await readFile(resolvedPath, 'utf8'));
} catch (err) {
if (err?.code !== 'ENOENT') {
throw err;
}
}
let outcome;
try {
outcome = upsertMarketplaceJsonEntry({ manifest: existing, meta });
} catch (err) {
if (err instanceof PublishError) {
console.error(`[publish] ${err.message}`);
process.exit(2);
}
throw err;
}
await mkdir(dirname(resolvedPath), { recursive: true });
await writeFile(resolvedPath, `${JSON.stringify(outcome.manifest, null, 2)}\n`, 'utf8');
return {
catalogPath: resolvedPath,
inserted: outcome.inserted,
entry: outcome.entry,
manifest: {
name: outcome.manifest.name,
version: outcome.manifest.version,
plugins: outcome.manifest.plugins.length,
},
};
}
async function runPluginYank(rest) {
const flags = parseFlags(rest, {
string: new Set(['daemon-url', 'reason', 'to']),
boolean: new Set(['help', 'h', 'json', 'open']),
});
if (rest.length === 0 || flags.help || flags.h) {
console.log(`Usage:
od plugin yank <vendor/plugin-name>@<version> --reason "<why>" [--to open-design] [--json]
Yanking never deletes metadata or bytes. It opens the registry review flow that
marks a version unresolvable for new installs while preserving lockfile replay.`);
process.exit(rest.length === 0 ? 2 : 0);
}
const spec = rest.find((a) => !a.startsWith('-') && a !== flags.reason && a !== flags.to);
const reason = typeof flags.reason === 'string' ? flags.reason.trim() : '';
const parsed = parseCliPluginSpecifier(spec);
if (!parsed.name || !parsed.range) {
console.error('Usage: od plugin yank <vendor/plugin-name>@<version> --reason "<why>"');
process.exit(2);
}
if (!reason) {
console.error('--reason is required for yanking');
process.exit(2);
}
const target = flags.to ?? 'open-design';
if (target !== 'open-design') {
console.error('Only --to open-design is supported in this v1 GitHub-backed yank flow.');
process.exit(2);
}
const title = `Yank ${parsed.name}@${parsed.range}`;
const body = [
`## Yank ${parsed.name}@${parsed.range}`,
'',
`Reason: ${reason}`,
'',
'Expected registry patch:',
'',
'```json',
JSON.stringify({
name: parsed.name,
version: parsed.range,
yanked: true,
yankReason: reason,
}, null, 2),
'```',
'',
'Generated by `od plugin yank`.',
].join('\n');
const params = new URLSearchParams({ title, body });
const payload = {
catalog: 'open-design',
name: parsed.name,
version: parsed.range,
reason,
url: `https://github.com/open-design/plugin-registry/issues/new?${params.toString()}`,
body,
};
if (flags.json) {
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
} else {
console.log(`[yank] ${payload.url}`);
console.log('---');
console.log(body);
}
if (flags.open) {
const opener = process.platform === 'darwin' ? 'open'
: process.platform === 'win32' ? 'start'
: 'xdg-open';
const { spawn } = await import('node:child_process');
spawn(opener, [payload.url], { detached: true, stdio: 'ignore' }).unref();
}
}
async function runPluginDoctor(rest) {
// Plan §3.HH1 — --strict promotes warnings to errors so CI can
// opt into 'no warnings allowed' mode without parsing the issue
@ -3160,13 +3595,17 @@ function printPluginHelp() {
(manifest parse + atom + ref checks).
od plugin pack <folder> [--out <path>] Build a .tgz archive of a plugin
folder for distribution.
od plugin publish <folder> --to open-design|anthropics-skills|awesome-agent-skills|clawhub|skills-sh
Prepare a registry submission link.
od plugin login [--host github.com] Authenticate registry publishing via gh.
od plugin whoami [--host github.com] Show the gh account used for publishing.
Common options:
--daemon-url <url> Open Design daemon HTTP base (default OD_DAEMON_URL or http://127.0.0.1:7456).
--json Emit raw JSON (suitable for scripts) instead of human-readable output.
Phase 1 only supports local-folder installs. The github / https tarball
sources arrive in Phase 2A. The marketplace surface comes in Phase 4.`);
Installs support local folders, github:owner/repo refs, HTTPS .tgz archives,
and bare marketplace names resolved through configured registry sources.`);
}
// ---------------------------------------------------------------------------

View file

@ -168,6 +168,12 @@ export function applyPlugin(input: ApplyInput): ApplyComputed {
pluginVersion: input.plugin.version,
manifestSourceDigest: digest,
sourceMarketplaceId: input.plugin.sourceMarketplaceId,
sourceMarketplaceEntryName: input.plugin.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: input.plugin.sourceMarketplaceEntryVersion,
marketplaceTrust: input.plugin.marketplaceTrust,
resolvedSource: input.plugin.resolvedSource,
resolvedRef: input.plugin.resolvedRef,
archiveIntegrity: input.plugin.archiveIntegrity,
pinnedRef: input.plugin.pinnedRef,
inputs: validated.coerced,
resolvedContext: resolved.context,

View file

@ -4,9 +4,12 @@
// folders that look like installable plugin manifests (a SKILL.md
// + open-design.json pair) and register every match into the
// `installed_plugins` table under `source_kind='bundled'` /
// `trust='bundled'`. Bundled plugins never enter the user's home
// install root; their fs_path stays inside the repo so a daemon
// upgrade rotates them in lockstep with the daemon code.
// `trust='bundled'`. Bundled plugins are the preinstalled cache of the
// official registry source: they can carry marketplace provenance while
// their bytes stay inside the runtime image for offline first-run use.
// They never enter the user's home install root; their fs_path stays
// inside the repo so a daemon upgrade rotates them in lockstep with the
// daemon code.
//
// `od plugin uninstall` of a bundled plugin is rejected by the
// installer (a future patch); for now, removing the row leaves the
@ -25,7 +28,7 @@ import {
upsertInstalledPlugin,
type RegistryRoots,
} from './registry.js';
import type { InstalledPluginRecord } from '@open-design/contracts';
import type { InstalledPluginRecord, MarketplaceTrust } from '@open-design/contracts';
type SqliteDb = Database.Database;
@ -38,6 +41,11 @@ export interface RegisterBundledPluginsInput {
// Optional registry roots override; bundled plugins do not write to
// userPluginsRoot but the installer code path expects one anyway.
roots?: RegistryRoots;
marketplaceProvenance?: {
sourceMarketplaceId: string;
marketplaceTrust: MarketplaceTrust;
entryNamePrefix: string;
};
}
export interface RegisterBundledPluginsResult {
@ -124,8 +132,24 @@ async function registerOne(args: {
args.warnings.push(`bundled plugin ${args.folderId} failed to parse: ${probe.errors.join('; ')}`);
return;
}
upsertInstalledPlugin(args.input.db, probe.record);
args.out.push(probe.record);
const record = withMarketplaceProvenance(probe.record, args.input.marketplaceProvenance);
upsertInstalledPlugin(args.input.db, record);
args.out.push(record);
}
function withMarketplaceProvenance(
record: InstalledPluginRecord,
provenance: RegisterBundledPluginsInput['marketplaceProvenance'],
): InstalledPluginRecord {
if (!provenance) return record;
return {
...record,
sourceMarketplaceId: provenance.sourceMarketplaceId,
sourceMarketplaceEntryName: `${provenance.entryNamePrefix}/${record.id}`,
sourceMarketplaceEntryVersion: record.version,
marketplaceTrust: provenance.marketplaceTrust,
resolvedSource: record.source,
};
}
async function pathExists(p: string): Promise<boolean> {

View file

@ -88,6 +88,7 @@ export * from './connector-gate.js';
export * from './export.js';
export * from './doctor.js';
export * from './installer.js';
export * from './lockfile.js';
export * from './persistence.js';
export * from './marketplaces.js';
export * from './pipeline.js';

View file

@ -16,8 +16,9 @@
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import { createHash } from 'node:crypto';
import { promises as fsp } from 'node:fs';
import { Readable } from 'node:stream';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { x as tarExtract } from 'tar';
import {
@ -25,11 +26,18 @@ import {
deleteInstalledPlugin,
resolvePluginFolder,
upsertInstalledPlugin,
type ResolveOptions,
type RegistryRoots,
} from './registry.js';
import type { InstalledPluginRecord, PluginSourceKind } from '@open-design/contracts';
import type {
InstalledPluginRecord,
MarketplaceTrust,
PluginSourceKind,
TrustTier,
} from '@open-design/contracts';
import type Database from 'better-sqlite3';
import { recordPluginEvent } from './events.js';
import { upsertPluginLockfileEntry } from './lockfile.js';
type SqliteDb = Database.Database;
@ -71,6 +79,17 @@ export interface InstallOptions {
// sets this to 'upgraded' so consumers can distinguish the two
// operations in the live event stream.
eventKind?: 'installed' | 'upgraded';
sourceMarketplaceId?: string;
sourceMarketplaceEntryName?: string;
sourceMarketplaceEntryVersion?: string;
marketplaceTrust?: MarketplaceTrust;
resolvedSource?: string;
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
// Optional runtime-data lockfile path. Daemon routes pass
// `<OD_DATA_DIR>/od-plugin-lock.json`; tests can point at temp dirs.
lockfilePath?: string;
}
export type ArchiveFetcher = (url: string) => Promise<{
@ -83,8 +102,20 @@ export type ArchiveFetcher = (url: string) => Promise<{
const DEFAULT_MAX_BYTES = 50 * 1024 * 1024;
const SAFE_BASENAME = /^[a-z0-9][a-z0-9._-]*$/;
const GITHUB_SOURCE_RE = /^github:([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)(?:@([A-Za-z0-9._/-]+))?(?:\/(.+))?$/;
const GITHUB_SOURCE_RE = /^github:([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)(.*)$/;
const HTTPS_SOURCE_RE = /^https:\/\//i;
const GITHUB_REF_SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
interface GithubArchiveCandidate {
ref: string;
subpath?: string;
}
interface ParsedGithubSource {
owner: string;
repo: string;
candidates: GithubArchiveCandidate[];
}
// Top-level dispatcher. Picks the backend off the source string and yields
// the same InstallEvent stream regardless of where the bytes came from.
@ -108,8 +139,8 @@ async function* installFromGithub(
db: SqliteDb,
opts: InstallOptions,
): AsyncGenerator<InstallEvent, void, void> {
const match = GITHUB_SOURCE_RE.exec(opts.source);
if (!match) {
const parsed = parseGithubSource(opts.source);
if (!parsed) {
yield {
kind: 'error',
message: `Malformed github source ${opts.source}; expected github:owner/repo[@ref][/subpath]`,
@ -117,14 +148,82 @@ async function* installFromGithub(
};
return;
}
const [, owner, repo, ref, subpath] = match;
const tarballUrl = `https://codeload.github.com/${owner}/${repo}/tar.gz/${ref ?? 'HEAD'}`;
let lastError: string | undefined;
const triedUrls: string[] = [];
for (const candidate of parsed.candidates) {
const tarballUrl = githubTarballUrl(parsed.owner, parsed.repo, candidate.ref);
triedUrls.push(tarballUrl);
const buffered: InstallEvent[] = [];
for await (const ev of installFromArchiveUrl(db, opts, tarballUrl, candidate.subpath)) {
buffered.push(ev);
if (ev.kind === 'error') {
lastError = ev.message;
break;
}
if (ev.kind === 'success') {
for (const bufferedEvent of buffered) yield bufferedEvent;
return;
}
}
if (!lastError || !isRetryableGithubCandidateError(lastError)) break;
}
yield {
kind: 'progress',
phase: 'resolving',
message: `Fetching ${tarballUrl}`,
kind: 'error',
message: lastError
? `${lastError}. Tried GitHub archive URL(s): ${triedUrls.join(', ')}`
: `GitHub source ${opts.source} did not produce an installable archive`,
warnings: [],
};
yield* installFromArchiveUrl(db, opts, tarballUrl, subpath);
}
function parseGithubSource(source: string): ParsedGithubSource | null {
const match = GITHUB_SOURCE_RE.exec(source);
if (!match) return null;
const [, owner, repo, rest = ''] = match;
if (!owner || !repo) return null;
if (rest.length === 0) {
return { owner, repo, candidates: [{ ref: 'HEAD' }] };
}
if (rest.startsWith('/')) {
const subpath = sanitizeRelativePath(rest.slice(1));
return subpath ? { owner, repo, candidates: [{ ref: 'HEAD', subpath }] } : null;
}
if (!rest.startsWith('@')) return null;
const refAndMaybeSubpath = rest.slice(1);
const parts = refAndMaybeSubpath.split('/');
if (parts.length === 0 || parts.some((part) => !GITHUB_REF_SEGMENT_RE.test(part))) {
return null;
}
const candidates: GithubArchiveCandidate[] = [];
const seen = new Set<string>();
for (let refPartCount = 1; refPartCount <= parts.length; refPartCount += 1) {
const ref = parts.slice(0, refPartCount).join('/');
const subpathParts = parts.slice(refPartCount);
const subpath = subpathParts.length > 0
? sanitizeRelativePath(subpathParts.join('/'))
: undefined;
const key = `${ref}\0${subpath ?? ''}`;
if (!seen.has(key)) {
seen.add(key);
candidates.push({ ref, ...(subpath ? { subpath } : {}) });
}
}
return candidates.length > 0 ? { owner, repo, candidates } : null;
}
function githubTarballUrl(owner: string, repo: string, ref: string): string {
const encodedRef = ref.split('/').map((part) => encodeURIComponent(part)).join('/');
return `https://codeload.github.com/${owner}/${repo}/tar.gz/${encodedRef}`;
}
function isRetryableGithubCandidateError(message: string): boolean {
return /^Fetch failed: 404\b/.test(message) || /^Subpath .+ not found inside archive$/.test(message);
}
// Plain `https://…tar.gz` / `https://…tgz` source.
@ -167,6 +266,26 @@ async function* installFromArchiveUrl(
};
return;
}
const archivePath = path.join(tmpRoot, 'archive.tgz');
let computedIntegrity: string;
try {
computedIntegrity = await writeArchiveAndDigest(resp.body, archivePath, maxBytes);
} catch (err) {
yield {
kind: 'error',
message: `Archive download failed: ${(err as Error).message}`,
warnings: [],
};
return;
}
if (opts.archiveIntegrity && !integrityMatches(opts.archiveIntegrity, computedIntegrity)) {
yield {
kind: 'error',
message: `Archive integrity mismatch: expected ${opts.archiveIntegrity}, got ${computedIntegrity}`,
warnings: [],
};
return;
}
yield { kind: 'progress', phase: 'copying', message: 'Extracting archive' };
let symlinkSeen = false;
let traversalSeen = false;
@ -178,7 +297,7 @@ async function* installFromArchiveUrl(
// any path-traversal segment; we then surface those as a clean
// install error instead of silently skipping unsafe entries.
await pipeline(
resp.body as NodeJS.ReadableStream,
fs.createReadStream(archivePath),
tarExtract({
cwd: tmpRoot,
strip: 1,
@ -247,6 +366,7 @@ async function* installFromArchiveUrl(
// provenance accurately.
yield* installFromLocalFolder(db, {
...opts,
archiveIntegrity: opts.archiveIntegrity ?? computedIntegrity,
source: opts.source,
// Drive the local backend through the staged folder; the
// override on `_stagedFolder` is internal and lets us re-use the
@ -269,6 +389,40 @@ async function defaultFetcher(url: string): ReturnType<ArchiveFetcher> {
};
}
async function writeArchiveAndDigest(
body: Readable,
archivePath: string,
maxBytes: number,
): Promise<string> {
const hash = createHash('sha256');
let bytes = 0;
const digestStream = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
if (bytes > maxBytes) {
callback(new Error(`Downloaded archive exceeds ${maxBytes} bytes`));
return;
}
hash.update(chunk);
callback(null, chunk);
},
});
await pipeline(body as NodeJS.ReadableStream, digestStream, fs.createWriteStream(archivePath));
return `sha256:${hash.digest('hex')}`;
}
function integrityMatches(expected: string, computed: string): boolean {
const normalizedExpected = expected.trim();
const normalizedComputed = computed.trim();
if (normalizedExpected === normalizedComputed) return true;
if (normalizedExpected.startsWith('sha256-')) {
const hex = normalizedComputed.replace(/^sha256:/, '');
const base64 = Buffer.from(hex, 'hex').toString('base64');
return normalizedExpected === `sha256-${base64}`;
}
return false;
}
async function measureTreeSize(root: string): Promise<number> {
let total = 0;
const queue: string[] = [root];
@ -327,12 +481,13 @@ export async function* installFromLocalFolder(
// installs.
yield { kind: 'progress', phase: 'parsing', message: 'Parsing manifest' };
const tentativeId = path.basename(sourceFolder).toLowerCase();
const probe = await resolvePluginFolder({
const probeOptions = buildResolveOptions({
folder: sourceFolder,
folderId: SAFE_BASENAME.test(tentativeId) ? tentativeId : 'plugin',
sourceKind: recordedSourceKind,
source: recordedSource,
});
}, opts);
const probe = await resolvePluginFolder(probeOptions);
if (!probe.ok) {
yield { kind: 'error', message: probe.errors.join('; '), warnings: probe.warnings };
return;
@ -366,12 +521,13 @@ export async function* installFromLocalFolder(
}
yield { kind: 'progress', phase: 'parsing', message: 'Re-parsing destination' };
const parsed = await resolvePluginFolder({
const parsedOptions = buildResolveOptions({
folder: destFolder,
folderId: pluginId,
sourceKind: recordedSourceKind,
source: recordedSource,
});
}, opts);
const parsed = await resolvePluginFolder(parsedOptions);
if (!parsed.ok) {
await fsp.rm(destFolder, { recursive: true, force: true }).catch(() => undefined);
yield { kind: 'error', message: parsed.errors.join('; '), warnings: [...warnings, ...parsed.warnings] };
@ -381,6 +537,9 @@ export async function* installFromLocalFolder(
yield { kind: 'progress', phase: 'persisting', message: 'Writing installed_plugins row' };
upsertInstalledPlugin(db, parsed.record);
if (opts.lockfilePath) {
await upsertPluginLockfileEntry(opts.lockfilePath, parsed.record);
}
// Plan §3.II1 / §3.JJ1 — emit 'plugin.installed' OR
// 'plugin.upgraded' (per opts.eventKind) so ops dashboards +
@ -394,6 +553,10 @@ export async function* installFromLocalFolder(
version: parsed.record.version,
sourceKind: parsed.record.sourceKind,
source: parsed.record.source,
sourceMarketplaceId: parsed.record.sourceMarketplaceId,
sourceMarketplaceEntryName: parsed.record.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: parsed.record.sourceMarketplaceEntryVersion,
marketplaceTrust: parsed.record.marketplaceTrust,
trust: parsed.record.trust,
warnings: warnings.length,
},
@ -481,4 +644,27 @@ function isSafeBasename(name: string): boolean {
return true;
}
function buildResolveOptions(
base: Pick<ResolveOptions, 'folder' | 'folderId' | 'sourceKind' | 'source'>,
opts: InstallOptions,
): ResolveOptions {
const resolveOptions: ResolveOptions = { ...base };
if (opts.sourceMarketplaceId) resolveOptions.sourceMarketplaceId = opts.sourceMarketplaceId;
if (opts.sourceMarketplaceEntryName) resolveOptions.sourceMarketplaceEntryName = opts.sourceMarketplaceEntryName;
if (opts.sourceMarketplaceEntryVersion) resolveOptions.sourceMarketplaceEntryVersion = opts.sourceMarketplaceEntryVersion;
if (opts.marketplaceTrust) {
resolveOptions.marketplaceTrust = opts.marketplaceTrust;
resolveOptions.trust = installedTrustFromMarketplace(opts.marketplaceTrust);
}
if (opts.resolvedSource) resolveOptions.resolvedSource = opts.resolvedSource;
if (opts.resolvedRef) resolveOptions.resolvedRef = opts.resolvedRef;
if (opts.manifestDigest) resolveOptions.manifestDigest = opts.manifestDigest;
if (opts.archiveIntegrity) resolveOptions.archiveIntegrity = opts.archiveIntegrity;
return resolveOptions;
}
function installedTrustFromMarketplace(trust: MarketplaceTrust): TrustTier {
return trust === 'restricted' ? 'restricted' : 'trusted';
}
export type { PluginSourceKind };

View file

@ -0,0 +1,87 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import type { InstalledPluginRecord } from '@open-design/contracts';
export interface PluginLockEntry {
name: string;
version: string;
source: string;
sourceKind: string;
sourceMarketplaceId?: string;
sourceMarketplaceEntryName?: string;
resolvedSource?: string;
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
lockedAt: number;
}
export interface PluginLockfile {
schemaVersion: 1;
plugins: Record<string, PluginLockEntry>;
}
export function defaultPluginLockfile(): PluginLockfile {
return { schemaVersion: 1, plugins: {} };
}
export function lockEntryFromInstalled(
plugin: InstalledPluginRecord,
lockedAt = Date.now(),
): PluginLockEntry {
const entry: PluginLockEntry = {
name: plugin.sourceMarketplaceEntryName ?? plugin.id,
version: plugin.sourceMarketplaceEntryVersion ?? plugin.version,
source: plugin.source,
sourceKind: plugin.sourceKind,
lockedAt,
};
if (plugin.sourceMarketplaceId) entry.sourceMarketplaceId = plugin.sourceMarketplaceId;
if (plugin.sourceMarketplaceEntryName) entry.sourceMarketplaceEntryName = plugin.sourceMarketplaceEntryName;
if (plugin.resolvedSource) entry.resolvedSource = plugin.resolvedSource;
if (plugin.resolvedRef) entry.resolvedRef = plugin.resolvedRef;
if (plugin.manifestDigest) entry.manifestDigest = plugin.manifestDigest;
if (plugin.archiveIntegrity) entry.archiveIntegrity = plugin.archiveIntegrity;
return entry;
}
export async function readPluginLockfile(filePath: string): Promise<PluginLockfile> {
try {
const raw = await readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as PluginLockfile;
return {
schemaVersion: 1,
plugins: parsed && typeof parsed.plugins === 'object' && parsed.plugins
? parsed.plugins
: {},
};
} catch {
return defaultPluginLockfile();
}
}
export async function writePluginLockfile(
filePath: string,
lockfile: PluginLockfile,
): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const sorted: PluginLockfile = {
schemaVersion: 1,
plugins: Object.fromEntries(
Object.entries(lockfile.plugins).sort(([left], [right]) => left.localeCompare(right)),
),
};
await writeFile(filePath, JSON.stringify(sorted, null, 2) + '\n', 'utf8');
}
export async function upsertPluginLockfileEntry(
filePath: string,
plugin: InstalledPluginRecord,
lockedAt = Date.now(),
): Promise<PluginLockfile> {
const lockfile = await readPluginLockfile(filePath);
const entry = lockEntryFromInstalled(plugin, lockedAt);
lockfile.plugins[entry.name] = entry;
await writePluginLockfile(filePath, lockfile);
return lockfile;
}

View file

@ -0,0 +1,81 @@
import type { MarketplaceManifest } from '@open-design/contracts';
import type { RegistryDoctorIssue, RegistryDoctorReport } from '@open-design/registry-protocol';
import { StaticRegistryBackend } from '../registry/static-backend.js';
export interface MarketplaceDoctorInput {
id: string;
trust: 'official' | 'trusted' | 'restricted';
manifest: MarketplaceManifest;
checkedAt?: number;
strict?: boolean;
}
export async function doctorMarketplace(
input: MarketplaceDoctorInput,
): Promise<RegistryDoctorReport & { warningsAsErrors: boolean }> {
const backend = new StaticRegistryBackend({
id: input.id,
trust: input.trust,
manifest: input.manifest,
});
const base = await backend.doctor();
const issues: RegistryDoctorIssue[] = [...base.issues];
const names = new Set<string>();
for (const entry of input.manifest.plugins ?? []) {
const lower = entry.name.toLowerCase();
if (names.has(lower)) {
issues.push({
severity: 'error',
code: 'duplicate-name',
message: 'Registry entries must have stable unique plugin ids.',
pluginName: entry.name,
});
}
names.add(lower);
if (entry.dist?.archive && !entry.dist.integrity && !entry.integrity) {
issues.push({
severity: 'error',
code: 'archive-integrity-required',
message: 'Archive distribution entries must include sha256 integrity.',
pluginName: entry.name,
});
}
if (entry.distTags?.latest) {
const hasLatest = (entry.versions ?? []).some((version) =>
version.version === entry.distTags?.latest && !version.yanked,
) || entry.version === entry.distTags.latest;
if (!hasLatest) {
issues.push({
severity: 'error',
code: 'bad-latest-tag',
message: 'distTags.latest must point at a non-yanked version.',
pluginName: entry.name,
});
}
}
const publisherId = entry.publisher?.id ?? entry.publisher?.github;
if (!publisherId) {
issues.push({
severity: 'warning',
code: 'missing-publisher',
message: 'Registry entry should declare publisher identity.',
pluginName: entry.name,
});
}
}
const strict = input.strict === true;
return {
ok: !issues.some((issue) => issue.severity === 'error') &&
(!strict || !issues.some((issue) => issue.severity === 'warning')),
backendId: base.backendId,
checkedAt: input.checkedAt ?? base.checkedAt,
entriesChecked: base.entriesChecked,
issues,
warningsAsErrors: strict,
};
}

View file

@ -23,6 +23,10 @@ import {
OPEN_DESIGN_PLUGIN_SPEC_VERSION,
type MarketplaceManifest,
} from '@open-design/contracts';
import {
parsePluginSpecifier,
resolveMarketplaceEntryVersion,
} from '../registry/versioning.js';
type SqliteDb = Database.Database;
@ -60,8 +64,20 @@ export interface AddMarketplaceFailure {
errors?: string[];
}
export interface EnsureMarketplaceManifestInput {
id: string;
url: string;
trust: MarketplaceTrustTier;
manifestText: string;
now?: number;
}
const HTTPS_RE = /^https:\/\//i;
function normalizeMarketplaceTrust(value: unknown): MarketplaceTrustTier {
return value === 'official' || value === 'trusted' ? value : 'restricted';
}
export async function addMarketplace(
db: SqliteDb,
input: AddMarketplaceInput,
@ -103,7 +119,7 @@ export async function addMarketplace(
}
const id = randomUUID();
const now = Date.now();
const trust = input.trust ?? 'restricted';
const trust = normalizeMarketplaceTrust(input.trust);
db.prepare(
`INSERT INTO plugin_marketplaces (id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
@ -124,6 +140,58 @@ export async function addMarketplace(
};
}
export function ensureMarketplaceManifest(
db: SqliteDb,
input: EnsureMarketplaceManifestInput,
): AddMarketplaceResult | AddMarketplaceFailure {
const parsed = parseMarketplace(input.manifestText);
if (!parsed.ok) {
return {
ok: false,
status: 422,
message: 'marketplace manifest failed validation',
errors: parsed.errors,
};
}
const now = input.now ?? Date.now();
const trust = normalizeMarketplaceTrust(input.trust);
const existing = getMarketplace(db, input.id);
db.prepare(`
INSERT INTO plugin_marketplaces (id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
url = excluded.url,
spec_version = excluded.spec_version,
version = excluded.version,
trust = excluded.trust,
manifest_json = excluded.manifest_json,
refreshed_at = excluded.refreshed_at
`).run(
input.id,
input.url,
parsed.manifest.specVersion,
parsed.manifest.version,
trust,
input.manifestText,
existing?.addedAt ?? now,
now,
);
return {
ok: true,
row: {
id: input.id,
url: input.url,
specVersion: parsed.manifest.specVersion,
version: parsed.manifest.version,
trust,
manifest: parsed.manifest,
addedAt: existing?.addedAt ?? now,
refreshedAt: now,
},
warnings: [],
};
}
export function listMarketplaces(db: SqliteDb): MarketplaceRow[] {
const rows = db
.prepare(`SELECT id, url, spec_version, version, trust, manifest_json, added_at, refreshed_at FROM plugin_marketplaces ORDER BY added_at ASC`)
@ -144,7 +212,7 @@ export function listMarketplaces(db: SqliteDb): MarketplaceRow[] {
url: r.url,
specVersion: r.spec_version || manifest.specVersion,
version: r.version === '0.0.0' ? manifest.version : r.version,
trust: r.trust,
trust: normalizeMarketplaceTrust(r.trust),
manifest,
addedAt: r.added_at,
refreshedAt: r.refreshed_at,
@ -174,7 +242,7 @@ export function getMarketplace(db: SqliteDb, id: string): MarketplaceRow | null
url: row.url,
specVersion: row.spec_version || manifest.specVersion,
version: row.version === '0.0.0' ? manifest.version : row.version,
trust: row.trust,
trust: normalizeMarketplaceTrust(row.trust),
manifest,
addedAt: row.added_at,
refreshedAt: row.refreshed_at,
@ -326,6 +394,9 @@ export interface ResolvedPluginEntry {
pluginName: string;
pluginVersion: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
description?: string;
}
@ -334,12 +405,15 @@ export function resolvePluginInMarketplaces(
pluginName: string,
): ResolvedPluginEntry | null {
const rows = listMarketplaces(db);
const target = pluginName.trim().toLowerCase();
const specifier = parsePluginSpecifier(pluginName);
const target = specifier.name.trim().toLowerCase();
if (!target) return null;
for (const row of rows) {
const entries = row.manifest.plugins ?? [];
for (const entry of entries) {
if (entry.name && entry.name.toLowerCase() === target) {
const resolvedVersion = resolveMarketplaceEntryVersion(entry, specifier.range);
if (!resolvedVersion) continue;
const result: ResolvedPluginEntry = {
marketplaceId: row.id,
marketplaceUrl: row.url,
@ -347,9 +421,12 @@ export function resolvePluginInMarketplaces(
marketplaceSpecVersion: row.specVersion,
marketplaceVersion: row.version,
pluginName: entry.name,
pluginVersion: entry.version,
source: entry.source,
pluginVersion: resolvedVersion.version,
source: resolvedVersion.source,
};
if (resolvedVersion.ref) result.ref = resolvedVersion.ref;
if (resolvedVersion.manifestDigest) result.manifestDigest = resolvedVersion.manifestDigest;
if (resolvedVersion.archiveIntegrity) result.archiveIntegrity = resolvedVersion.archiveIntegrity;
if (entry.description) result.description = entry.description;
return result;
}

View file

@ -25,6 +25,13 @@ export function migratePlugins(db: SqliteDb): void {
pinned_ref TEXT,
source_digest TEXT,
source_marketplace_id TEXT,
source_marketplace_entry_name TEXT,
source_marketplace_entry_version TEXT,
marketplace_trust TEXT,
resolved_source TEXT,
resolved_ref TEXT,
manifest_digest TEXT,
archive_integrity TEXT,
trust TEXT NOT NULL,
capabilities_granted TEXT NOT NULL,
manifest_json TEXT NOT NULL,
@ -57,6 +64,12 @@ export function migratePlugins(db: SqliteDb): void {
plugin_version TEXT NOT NULL,
manifest_source_digest TEXT NOT NULL,
source_marketplace_id TEXT,
source_marketplace_entry_name TEXT,
source_marketplace_entry_version TEXT,
marketplace_trust TEXT,
resolved_source TEXT,
resolved_ref TEXT,
archive_integrity TEXT,
pinned_ref TEXT,
task_kind TEXT NOT NULL,
inputs_json TEXT NOT NULL,
@ -140,10 +153,33 @@ export function migratePlugins(db: SqliteDb): void {
}
db.exec(`CREATE INDEX IF NOT EXISTS idx_marketplaces_version ON plugin_marketplaces(version)`);
const installedCols = db.prepare(`PRAGMA table_info(installed_plugins)`).all() as DbRow[];
for (const [name, ddl] of [
['source_marketplace_entry_name', `ALTER TABLE installed_plugins ADD COLUMN source_marketplace_entry_name TEXT`],
['source_marketplace_entry_version', `ALTER TABLE installed_plugins ADD COLUMN source_marketplace_entry_version TEXT`],
['marketplace_trust', `ALTER TABLE installed_plugins ADD COLUMN marketplace_trust TEXT`],
['resolved_source', `ALTER TABLE installed_plugins ADD COLUMN resolved_source TEXT`],
['resolved_ref', `ALTER TABLE installed_plugins ADD COLUMN resolved_ref TEXT`],
['manifest_digest', `ALTER TABLE installed_plugins ADD COLUMN manifest_digest TEXT`],
['archive_integrity', `ALTER TABLE installed_plugins ADD COLUMN archive_integrity TEXT`],
] as const) {
if (!installedCols.some((c) => c['name'] === name)) db.exec(ddl);
}
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'`);
}
for (const [name, ddl] of [
['source_marketplace_entry_name', `ALTER TABLE applied_plugin_snapshots ADD COLUMN source_marketplace_entry_name TEXT`],
['source_marketplace_entry_version', `ALTER TABLE applied_plugin_snapshots ADD COLUMN source_marketplace_entry_version TEXT`],
['marketplace_trust', `ALTER TABLE applied_plugin_snapshots ADD COLUMN marketplace_trust TEXT`],
['resolved_source', `ALTER TABLE applied_plugin_snapshots ADD COLUMN resolved_source TEXT`],
['resolved_ref', `ALTER TABLE applied_plugin_snapshots ADD COLUMN resolved_ref TEXT`],
['archive_integrity', `ALTER TABLE applied_plugin_snapshots ADD COLUMN archive_integrity TEXT`],
] as const) {
if (!snapshotCols.some((c) => c['name'] === name)) db.exec(ddl);
}
// Back-reference columns. SQLite has no IF NOT EXISTS for ALTER; check
// pragma_table_info first. Mirrors the upstream pattern in db.ts.

View file

@ -11,6 +11,7 @@
// - awesome-agent-skills → VoltAgent/awesome-agent-skills
// - clawhub → openclaw/clawhub
// - skills-sh → skills.sh discovery hint
// - open-design → open-design/plugin-registry
//
// The function is pure: it accepts the plugin's metadata and returns
// the catalog target description. The CLI is the side-effect-bearing
@ -20,7 +21,8 @@ export type PublishCatalog =
| 'anthropics-skills'
| 'awesome-agent-skills'
| 'clawhub'
| 'skills-sh';
| 'skills-sh'
| 'open-design';
export interface PublishMetadata {
// Plugin name + version come from the manifest. The repo URL is the
@ -45,6 +47,37 @@ export interface PublishLink {
prBody: string;
}
export interface MarketplaceJsonManifest {
specVersion: string;
name: string;
version: string;
generatedAt?: string;
plugins: MarketplaceJsonEntry[];
[key: string]: unknown;
}
export interface MarketplaceJsonEntry {
name: string;
source: string;
version: string;
title?: string;
description?: string;
publisher?: {
name?: string;
github?: string;
url?: string;
};
homepage?: string;
repo?: string;
[key: string]: unknown;
}
export interface MarketplaceJsonPublishOutcome {
manifest: MarketplaceJsonManifest;
entry: MarketplaceJsonEntry;
inserted: boolean;
}
export class PublishError extends Error {
constructor(message: string) {
super(message);
@ -57,6 +90,7 @@ const KNOWN_TARGETS = new Set<PublishCatalog>([
'awesome-agent-skills',
'clawhub',
'skills-sh',
'open-design',
]);
export function buildPublishLink(args: {
@ -103,11 +137,88 @@ export function buildPublishLink(args: {
].join('\n'),
};
}
case 'open-design': {
const bodyWithRegistry = [
body,
'',
'## Open Design registry entry',
'',
'- Target path: `community/<vendor>/<plugin-name>/open-design.json`',
'- Generated index: `open-design-marketplace.json`',
'- Required checks: `od plugin validate`, `od plugin pack`, integrity digest, preview smoke.',
].join('\n');
const url = newIssueUrl('open-design/plugin-registry', title, bodyWithRegistry);
return {
catalog: args.catalog,
catalogLabel: 'open-design/plugin-registry',
url,
prBody: bodyWithRegistry,
};
}
}
// Unreachable; keeps the compiler happy.
throw new PublishError(`unhandled catalog: ${String(args.catalog)}`);
}
export function buildMarketplaceJsonEntry(meta: PublishMetadata): MarketplaceJsonEntry {
if (!meta.pluginId.includes('/')) {
throw new PublishError('marketplace-json publish requires a stable namespaced id: vendor/plugin-name');
}
if (!meta.repoUrl) {
throw new PublishError('marketplace-json publish requires meta.repoUrl');
}
const parsedRepo = parseGithubRepo(meta.repoUrl);
const entry: MarketplaceJsonEntry = {
name: meta.pluginId,
source: parsedRepo.source,
version: meta.pluginVersion,
repo: meta.repoUrl,
homepage: meta.repoUrl,
publisher: {
name: parsedRepo.owner,
github: parsedRepo.owner,
url: `https://github.com/${parsedRepo.owner}`,
},
};
if (meta.pluginTitle) entry.title = meta.pluginTitle;
if (meta.pluginDescription) entry.description = meta.pluginDescription;
return entry;
}
export function upsertMarketplaceJsonEntry(args: {
manifest?: Partial<MarketplaceJsonManifest> | null;
meta: PublishMetadata;
generatedAt?: string;
}): MarketplaceJsonPublishOutcome {
const entry = buildMarketplaceJsonEntry(args.meta);
const existing = args.manifest ?? {};
const plugins = Array.isArray(existing.plugins) ? existing.plugins : [];
let inserted = true;
const nextPlugins = plugins.map((plugin) => {
if (plugin?.name === entry.name) {
inserted = false;
return {
...plugin,
...entry,
};
}
return plugin;
});
if (inserted) {
nextPlugins.push(entry);
}
nextPlugins.sort((a, b) => String(a.name).localeCompare(String(b.name)));
const manifest: MarketplaceJsonManifest = {
...existing,
specVersion: typeof existing.specVersion === 'string' ? existing.specVersion : '1.0.0',
name: typeof existing.name === 'string' ? existing.name : 'open-design-marketplace',
version: typeof existing.version === 'string' ? existing.version : '1.0.0',
generatedAt: args.generatedAt ?? new Date().toISOString(),
plugins: nextPlugins,
};
return { manifest, entry, inserted };
}
function newIssueUrl(repo: string, title: string, body: string): string {
const params = new URLSearchParams();
params.set('title', title);
@ -115,6 +226,38 @@ function newIssueUrl(repo: string, title: string, body: string): string {
return `https://github.com/${repo}/issues/new?${params.toString()}`;
}
function parseGithubRepo(repoUrl: string): { owner: string; repo: string; source: string } {
let url: URL;
try {
url = new URL(repoUrl);
} catch {
throw new PublishError(`unsupported repo URL: ${repoUrl}`);
}
if (url.hostname.toLowerCase() !== 'github.com') {
throw new PublishError('marketplace-json publish currently requires a github.com repo URL');
}
const parts = url.pathname.split('/').filter(Boolean);
const owner = parts[0];
const repo = parts[1]?.replace(/\.git$/i, '');
if (!owner || !repo) {
throw new PublishError(`unsupported GitHub repo URL: ${repoUrl}`);
}
if (parts[2] === 'tree' && parts[3]) {
const ref = parts[3];
const subpath = parts.slice(4).join('/');
return {
owner,
repo,
source: `github:${owner}/${repo}@${ref}${subpath ? `/${subpath}` : ''}`,
};
}
return {
owner,
repo,
source: `github:${owner}/${repo}`,
};
}
function renderPrBody(m: PublishMetadata): string {
const lines: string[] = [];
lines.push(`## ${m.pluginTitle ?? m.pluginId}`);

View file

@ -27,6 +27,7 @@ import {
} from '@open-design/plugin-runtime';
import type {
InstalledPluginRecord,
MarketplaceTrust,
PluginManifest,
PluginSourceKind,
TrustTier,
@ -62,6 +63,14 @@ export interface ResolveOptions {
pinnedRef?: string;
trust?: TrustTier;
capabilitiesGranted?: string[];
sourceMarketplaceId?: string;
sourceMarketplaceEntryName?: string;
sourceMarketplaceEntryVersion?: string;
marketplaceTrust?: MarketplaceTrust;
resolvedSource?: string;
resolvedRef?: string;
manifestDigest?: string;
archiveIntegrity?: string;
}
export interface ResolveOutcome {
@ -156,7 +165,14 @@ export async function resolvePluginFolder(opts: ResolveOptions): Promise<Resolve
sourceKind: opts.sourceKind ?? 'local',
source: opts.source ?? folder,
pinnedRef: opts.pinnedRef,
sourceMarketplaceId: undefined,
sourceMarketplaceId: opts.sourceMarketplaceId,
sourceMarketplaceEntryName: opts.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: opts.sourceMarketplaceEntryVersion,
marketplaceTrust: opts.marketplaceTrust,
resolvedSource: opts.resolvedSource,
resolvedRef: opts.resolvedRef,
manifestDigest: opts.manifestDigest,
archiveIntegrity: opts.archiveIntegrity,
trust: opts.trust ?? 'restricted',
capabilitiesGranted: opts.capabilitiesGranted ?? defaultRestrictedCapabilities(),
manifest,
@ -189,6 +205,13 @@ export function rowToInstalledPlugin(row: DbRow): InstalledPluginRecord {
pinnedRef: row['pinned_ref'] != null ? String(row['pinned_ref']) : undefined,
sourceDigest: row['source_digest'] != null ? String(row['source_digest']) : undefined,
sourceMarketplaceId: row['source_marketplace_id'] != null ? String(row['source_marketplace_id']) : undefined,
sourceMarketplaceEntryName: row['source_marketplace_entry_name'] != null ? String(row['source_marketplace_entry_name']) : undefined,
sourceMarketplaceEntryVersion: row['source_marketplace_entry_version'] != null ? String(row['source_marketplace_entry_version']) : undefined,
marketplaceTrust: row['marketplace_trust'] != null ? row['marketplace_trust'] as MarketplaceTrust : undefined,
resolvedSource: row['resolved_source'] != null ? String(row['resolved_source']) : undefined,
resolvedRef: row['resolved_ref'] != null ? String(row['resolved_ref']) : undefined,
manifestDigest: row['manifest_digest'] != null ? String(row['manifest_digest']) : undefined,
archiveIntegrity: row['archive_integrity'] != null ? String(row['archive_integrity']) : undefined,
trust: row['trust'] as TrustTier,
capabilitiesGranted: Array.isArray(capabilities) ? capabilities : [],
manifest,
@ -212,10 +235,13 @@ export function upsertInstalledPlugin(db: SqliteDb, record: InstalledPluginRecor
db.prepare(`
INSERT INTO installed_plugins (
id, title, version, source_kind, source, pinned_ref, source_digest,
source_marketplace_id, trust, capabilities_granted, manifest_json,
source_marketplace_id, source_marketplace_entry_name,
source_marketplace_entry_version, marketplace_trust, resolved_source,
resolved_ref, manifest_digest, archive_integrity,
trust, capabilities_granted, manifest_json,
fs_path, installed_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
version = excluded.version,
@ -224,6 +250,13 @@ export function upsertInstalledPlugin(db: SqliteDb, record: InstalledPluginRecor
pinned_ref = excluded.pinned_ref,
source_digest = excluded.source_digest,
source_marketplace_id = excluded.source_marketplace_id,
source_marketplace_entry_name = excluded.source_marketplace_entry_name,
source_marketplace_entry_version = excluded.source_marketplace_entry_version,
marketplace_trust = excluded.marketplace_trust,
resolved_source = excluded.resolved_source,
resolved_ref = excluded.resolved_ref,
manifest_digest = excluded.manifest_digest,
archive_integrity = excluded.archive_integrity,
trust = excluded.trust,
capabilities_granted = excluded.capabilities_granted,
manifest_json = excluded.manifest_json,
@ -238,6 +271,13 @@ export function upsertInstalledPlugin(db: SqliteDb, record: InstalledPluginRecor
record.pinnedRef ?? null,
record.sourceDigest ?? null,
record.sourceMarketplaceId ?? null,
record.sourceMarketplaceEntryName ?? null,
record.sourceMarketplaceEntryVersion ?? null,
record.marketplaceTrust ?? null,
record.resolvedSource ?? null,
record.resolvedRef ?? null,
record.manifestDigest ?? null,
record.archiveIntegrity ?? null,
record.trust,
JSON.stringify(record.capabilitiesGranted ?? []),
JSON.stringify(record.manifest),

View file

@ -264,6 +264,12 @@ export function resolvePluginSnapshot(input: ResolveSnapshotInput): ResolveSnaps
pluginDescription: result.appliedPlugin.pluginDescription,
manifestSourceDigest: applyComputed.manifestSourceDigest,
sourceMarketplaceId: result.appliedPlugin.sourceMarketplaceId ?? null,
sourceMarketplaceEntryName: result.appliedPlugin.sourceMarketplaceEntryName ?? null,
sourceMarketplaceEntryVersion: result.appliedPlugin.sourceMarketplaceEntryVersion ?? null,
marketplaceTrust: result.appliedPlugin.marketplaceTrust ?? null,
resolvedSource: result.appliedPlugin.resolvedSource ?? null,
resolvedRef: result.appliedPlugin.resolvedRef ?? null,
archiveIntegrity: result.appliedPlugin.archiveIntegrity ?? null,
pinnedRef: result.appliedPlugin.pinnedRef ?? null,
taskKind: result.appliedPlugin.taskKind,
inputs: result.appliedPlugin.inputs,

View file

@ -41,6 +41,12 @@ export interface CreateSnapshotInput {
pluginDescription?: string | undefined;
manifestSourceDigest: string;
sourceMarketplaceId?: string | null | undefined;
sourceMarketplaceEntryName?: string | null | undefined;
sourceMarketplaceEntryVersion?: string | null | undefined;
marketplaceTrust?: 'official' | 'trusted' | 'restricted' | null | undefined;
resolvedSource?: string | null | undefined;
resolvedRef?: string | null | undefined;
archiveIntegrity?: string | null | undefined;
pinnedRef?: string | null | undefined;
taskKind: AppliedPluginSnapshot['taskKind'];
inputs: Record<string, string | number | boolean>;
@ -72,14 +78,16 @@ 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_spec_version, plugin_version,
manifest_source_digest, source_marketplace_id, pinned_ref, task_kind,
manifest_source_digest, source_marketplace_id, source_marketplace_entry_name,
source_marketplace_entry_version, marketplace_trust, resolved_source,
resolved_ref, archive_integrity, pinned_ref, task_kind,
inputs_json, resolved_context_json, pipeline_json, genui_surfaces_json,
capabilities_granted, capabilities_required, assets_staged_json,
connectors_required_json, connectors_resolved_json, mcp_servers_json,
plugin_title, plugin_description, query_text,
status, applied_at, expires_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'fresh', ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'fresh', ?, ?)
`).run(
id,
input.projectId,
@ -90,6 +98,12 @@ export function createSnapshot(db: SqliteDb, input: CreateSnapshotInput): Applie
input.pluginVersion,
input.manifestSourceDigest,
input.sourceMarketplaceId ?? null,
input.sourceMarketplaceEntryName ?? null,
input.sourceMarketplaceEntryVersion ?? null,
input.marketplaceTrust ?? null,
input.resolvedSource ?? null,
input.resolvedRef ?? null,
input.archiveIntegrity ?? null,
input.pinnedRef ?? null,
input.taskKind,
JSON.stringify(input.inputs),
@ -287,6 +301,12 @@ function buildSnapshot(args: {
pluginVersion: input.pluginVersion,
manifestSourceDigest: input.manifestSourceDigest,
sourceMarketplaceId: input.sourceMarketplaceId ?? undefined,
sourceMarketplaceEntryName: input.sourceMarketplaceEntryName ?? undefined,
sourceMarketplaceEntryVersion: input.sourceMarketplaceEntryVersion ?? undefined,
marketplaceTrust: input.marketplaceTrust ?? undefined,
resolvedSource: input.resolvedSource ?? undefined,
resolvedRef: input.resolvedRef ?? undefined,
archiveIntegrity: input.archiveIntegrity ?? undefined,
pinnedRef: input.pinnedRef ?? undefined,
inputs: input.inputs,
resolvedContext: input.resolvedContext,
@ -317,6 +337,12 @@ export function rowToSnapshot(row: DbRow): AppliedPluginSnapshot {
pluginVersion: String(row['plugin_version']),
manifestSourceDigest: String(row['manifest_source_digest']),
sourceMarketplaceId: row['source_marketplace_id'] != null ? String(row['source_marketplace_id']) : undefined,
sourceMarketplaceEntryName: row['source_marketplace_entry_name'] != null ? String(row['source_marketplace_entry_name']) : undefined,
sourceMarketplaceEntryVersion: row['source_marketplace_entry_version'] != null ? String(row['source_marketplace_entry_version']) : undefined,
marketplaceTrust: row['marketplace_trust'] != null ? row['marketplace_trust'] as AppliedPluginSnapshot['marketplaceTrust'] : undefined,
resolvedSource: row['resolved_source'] != null ? String(row['resolved_source']) : undefined,
resolvedRef: row['resolved_ref'] != null ? String(row['resolved_ref']) : undefined,
archiveIntegrity: row['archive_integrity'] != null ? String(row['archive_integrity']) : undefined,
pinnedRef: row['pinned_ref'] != null ? String(row['pinned_ref']) : undefined,
inputs: parseJsonOr<Record<string, string | number | boolean>>(row['inputs_json'], {}),
resolvedContext: parseJsonOr<ResolvedContext>(row['resolved_context_json'], { items: [] }),

View file

@ -122,6 +122,11 @@ type ProjectMetadata = {
url?: string | null;
} | null;
} | null;
contextPlugins?: Array<{
id?: string | null;
title?: string | null;
description?: string | null;
}> | null;
};
type ProjectTemplate = { name: string; description?: string | null; files: Array<{ name: string; content: string }> };
type AudioVoiceOption = {
@ -809,6 +814,25 @@ function renderMetadataBlock(
);
}
if (Array.isArray(metadata.contextPlugins) && metadata.contextPlugins.length > 0) {
lines.push('');
lines.push('### @ plugin context');
lines.push(
'The user selected these plugins as additive context via @ mentions. Treat them as requested references to combine with the brief; only the explicit active plugin block, if present, is the executable/pinned plugin snapshot.',
);
for (const plugin of metadata.contextPlugins) {
const id = typeof plugin.id === 'string' ? plugin.id : '';
const title = typeof plugin.title === 'string' && plugin.title.trim().length > 0
? plugin.title.trim()
: id;
if (!id && !title) continue;
const description = typeof plugin.description === 'string' && plugin.description.trim().length > 0
? `${plugin.description.trim()}`
: '';
lines.push(`- ${title}${id ? ` (\`${id}\`)` : ''}${description}`);
}
}
// Curated prompt template reference for image/video projects. Inlined
// verbatim (with light truncation) so the agent can borrow structure,
// mood and phrasing without a separate fetch. The user may have edited

View file

@ -0,0 +1,127 @@
import type Database from 'better-sqlite3';
import type { MarketplaceManifest, MarketplacePluginEntry } from '@open-design/contracts';
import type {
RegistryEntry,
RegistryPublishOutcome,
RegistryPublishRequest,
RegistryTrust,
RegistryYankOutcome,
} from '@open-design/registry-protocol';
import { StaticRegistryBackend, toRegistryEntry } from './static-backend.js';
type SqliteDb = Database.Database;
export interface DatabaseRegistryBackendOptions {
id: string;
trust?: RegistryTrust;
db: SqliteDb;
}
export class DatabaseRegistryBackend extends StaticRegistryBackend {
readonly db: SqliteDb;
constructor(options: DatabaseRegistryBackendOptions) {
ensureRegistryTables(options.db);
super({
id: options.id,
kind: 'db',
trust: options.trust ?? 'restricted',
manifest: manifestFromDb(options.db, options.id),
});
this.db = options.db;
}
async publish(request: RegistryPublishRequest): Promise<RegistryPublishOutcome> {
upsertRegistryEntry(this.db, this.id, request.entry);
const [vendor, name] = request.entry.name.split('/');
return {
ok: true,
dryRun: false,
changedFiles: [
`db://${this.id}/plugins/${vendor}/${name}`,
`db://${this.id}/plugins/${vendor}/${name}/versions/${request.entry.version}`,
],
warnings: [],
};
}
async yank(name: string, version: string, reason: string): Promise<RegistryYankOutcome> {
const row = this.db.prepare(`
SELECT entry_json FROM registry_entries WHERE backend_id = ? AND name = ?
`).get(this.id, name) as { entry_json: string } | undefined;
if (!row) {
return { ok: false, name, version, reason, warnings: [`${name} not found`] };
}
const entry = JSON.parse(row.entry_json) as RegistryEntry;
const versions = entry.versions ?? [{ version: entry.version, source: entry.source }];
const nextVersions = versions.map((item) => item.version === version
? { ...item, yanked: true, yankedAt: new Date().toISOString(), yankReason: reason }
: item);
upsertRegistryEntry(this.db, this.id, {
...entry,
versions: nextVersions,
...(entry.version === version
? { yanked: true, yankedAt: new Date().toISOString(), yankReason: reason }
: {}),
});
return { ok: true, name, version, reason, warnings: [] };
}
protected override getManifest(): MarketplaceManifest {
return manifestFromDb(this.db, this.id);
}
}
export function ensureRegistryTables(db: SqliteDb): void {
db.exec(`
CREATE TABLE IF NOT EXISTS registry_entries (
backend_id TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
entry_json TEXT NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (backend_id, name)
)
`);
}
export function upsertRegistryEntry(
db: SqliteDb,
backendId: string,
entry: RegistryEntry,
now = Date.now(),
): void {
db.prepare(`
INSERT INTO registry_entries (backend_id, name, version, entry_json, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(backend_id, name) DO UPDATE SET
version = excluded.version,
entry_json = excluded.entry_json,
updated_at = excluded.updated_at
`).run(backendId, entry.name, entry.version, JSON.stringify(entry), now);
}
function manifestFromDb(db: SqliteDb, backendId: string): MarketplaceManifest {
const rows = db.prepare(`
SELECT entry_json FROM registry_entries WHERE backend_id = ? ORDER BY name ASC
`).all(backendId) as Array<{ entry_json: string }>;
const plugins: MarketplacePluginEntry[] = [];
for (const row of rows) {
const entry = JSON.parse(row.entry_json) as RegistryEntry;
const marketplaceEntry: MarketplacePluginEntry = {
...entry,
name: entry.name,
source: entry.source,
version: entry.version,
};
if (toRegistryEntry(marketplaceEntry)) {
plugins.push(marketplaceEntry);
}
}
return {
specVersion: '1.0.0',
name: backendId,
version: '0.0.0',
plugins,
};
}

View file

@ -0,0 +1,166 @@
import type { MarketplaceManifest } from '@open-design/contracts';
import type {
RegistryPublishOutcome,
RegistryPublishRequest,
RegistryYankOutcome,
} from '@open-design/registry-protocol';
import { StaticRegistryBackend } from './static-backend.js';
export interface GithubRegistryClient {
readMarketplace(owner: string, repo: string, ref: string, path: string): Promise<MarketplaceManifest>;
createPublishPullRequest?(request: GithubPublishMutation): Promise<{ url: string }>;
}
export interface GithubPublishMutation {
owner: string;
repo: string;
baseRef: string;
branchName: string;
title: string;
body: string;
files: Array<{ path: string; content: string }>;
}
export interface GithubRegistryBackendOptions {
id: string;
owner: string;
repo: string;
ref?: string;
marketplacePath?: string;
client: GithubRegistryClient;
}
export class GithubRegistryBackend extends StaticRegistryBackend {
readonly owner: string;
readonly repo: string;
readonly ref: string;
readonly marketplacePath: string;
readonly client: GithubRegistryClient;
private constructor(options: GithubRegistryBackendOptions & { manifest: MarketplaceManifest }) {
super({
id: options.id,
kind: 'github',
trust: 'official',
manifest: options.manifest,
});
this.owner = options.owner;
this.repo = options.repo;
this.ref = options.ref ?? 'main';
this.marketplacePath = options.marketplacePath ?? 'plugins/registry/official/open-design-marketplace.json';
this.client = options.client;
}
static async create(options: GithubRegistryBackendOptions): Promise<GithubRegistryBackend> {
const ref = options.ref ?? 'main';
const marketplacePath = options.marketplacePath ?? 'plugins/registry/official/open-design-marketplace.json';
const manifest = await options.client.readMarketplace(
options.owner,
options.repo,
ref,
marketplacePath,
);
return new GithubRegistryBackend({ ...options, ref, marketplacePath, manifest });
}
async publish(request: RegistryPublishRequest): Promise<RegistryPublishOutcome> {
const [vendor, name] = request.entry.name.split('/');
const version = request.entry.version;
const root = `plugins/${vendor}/${name}`;
const files = [
{
path: `${root}/entry.json`,
content: JSON.stringify(request.entry, null, 2) + '\n',
},
{
path: `${root}/versions/${version}.json`,
content: JSON.stringify({
...request.entry,
publishedAt: new Date().toISOString(),
tag: request.tag ?? 'latest',
}, null, 2) + '\n',
},
];
if (request.dryRun || !this.client.createPublishPullRequest) {
return {
ok: true,
dryRun: true,
changedFiles: files.map((file) => file.path),
warnings: this.client.createPublishPullRequest
? []
: ['github mutation client unavailable; emitted dry-run payload only'],
};
}
const mutation: GithubPublishMutation = {
owner: this.owner,
repo: this.repo,
baseRef: this.ref,
branchName: `publish/${vendor}-${name}-${version}`,
title: `Add ${request.entry.name}@${version}`,
body: renderPublishBody(request),
files,
};
const pr = await this.client.createPublishPullRequest(mutation);
return {
ok: true,
dryRun: false,
pullRequestUrl: pr.url,
changedFiles: files.map((file) => file.path),
warnings: [],
};
}
async yank(name: string, version: string, reason: string): Promise<RegistryYankOutcome> {
const [vendor, pluginName] = name.split('/');
const path = `plugins/${vendor}/${pluginName}/versions/${version}.json`;
if (!this.client.createPublishPullRequest) {
return {
ok: true,
name,
version,
reason,
warnings: ['github mutation client unavailable; emitted dry-run yank only'],
};
}
const mutation: GithubPublishMutation = {
owner: this.owner,
repo: this.repo,
baseRef: this.ref,
branchName: `yank/${vendor}-${pluginName}-${version}`,
title: `Yank ${name}@${version}`,
body: `Yank ${name}@${version}\n\nReason: ${reason}\n`,
files: [
{
path,
content: JSON.stringify({
name,
version,
yanked: true,
yankedAt: new Date().toISOString(),
yankReason: reason,
}, null, 2) + '\n',
},
],
};
const pr = await this.client.createPublishPullRequest(mutation);
return { ok: true, name, version, reason, pullRequestUrl: pr.url, warnings: [] };
}
}
function renderPublishBody(request: RegistryPublishRequest): string {
return [
`Publish ${request.entry.name}@${request.entry.version}`,
'',
request.entry.description ?? '',
'',
'## Registry metadata',
'',
`- source: ${request.entry.source}`,
`- integrity: ${request.entry.integrity ?? request.entry.dist?.integrity ?? '(pending)'}`,
`- manifestDigest: ${request.entry.manifestDigest ?? request.entry.dist?.manifestDigest ?? '(pending)'}`,
`- capabilities: ${(request.entry.capabilitiesSummary ?? []).join(', ') || '(none declared)'}`,
request.changelog ? `\n## Changelog\n\n${request.changelog}` : '',
].filter(Boolean).join('\n');
}

View file

@ -0,0 +1,191 @@
import type { MarketplaceManifest, MarketplacePluginEntry } from '@open-design/contracts';
import type {
RegistryBackend,
RegistryDoctorReport,
RegistryEntry,
RegistrySearchQuery,
RegistrySearchResult,
RegistryTrust,
ResolvedRegistryEntry,
} from '@open-design/registry-protocol';
import {
RegistryEntrySchema,
RegistrySearchQuerySchema,
} from '@open-design/registry-protocol';
import {
parsePluginSpecifier,
resolveMarketplaceEntryVersion,
} from './versioning.js';
export interface StaticRegistryBackendOptions {
id: string;
kind?: 'github' | 'http' | 'local' | 'db';
trust: RegistryTrust;
manifest: MarketplaceManifest;
}
export class StaticRegistryBackend implements RegistryBackend {
readonly id: string;
readonly kind: 'github' | 'http' | 'local' | 'db';
readonly trust: RegistryTrust;
protected readonly manifestData: MarketplaceManifest;
constructor(options: StaticRegistryBackendOptions) {
this.id = options.id;
this.kind = options.kind ?? 'http';
this.trust = options.trust;
this.manifestData = options.manifest;
}
async list(): Promise<RegistryEntry[]> {
return (this.getManifest().plugins ?? [])
.filter((entry) => !entry.yanked)
.flatMap((entry) => {
const parsed = toRegistryEntry(entry);
return parsed ? [parsed] : [];
});
}
async search(input: RegistrySearchQuery): Promise<RegistrySearchResult[]> {
const query = RegistrySearchQuerySchema.parse(input);
const terms = query.query.toLowerCase().split(/\s+/g).filter(Boolean);
const tags = new Set((query.tags ?? []).map((tag) => tag.toLowerCase()));
const entries = await this.list();
const results: RegistrySearchResult[] = [];
for (const entry of entries) {
if (tags.size > 0) {
const entryTags = new Set((entry.tags ?? []).map((tag) => tag.toLowerCase()));
if (![...tags].every((tag) => entryTags.has(tag))) continue;
}
const haystack = [
entry.name,
entry.title ?? '',
entry.description ?? '',
...(entry.tags ?? []),
...(entry.capabilitiesSummary ?? []),
entry.publisher?.id ?? '',
entry.publisher?.github ?? '',
].join(' ').toLowerCase();
const matched = terms.filter((term) => haystack.includes(term));
if (terms.length > 0 && matched.length === 0) continue;
results.push({
entry,
score: terms.length === 0 ? 0 : matched.length / terms.length,
matched,
});
}
return results
.sort((left, right) => right.score - left.score || left.entry.name.localeCompare(right.entry.name))
.slice(0, query.limit ?? 100);
}
async resolve(name: string, range?: string): Promise<ResolvedRegistryEntry | null> {
const parsed = parsePluginSpecifier(range ? `${name}@${range}` : name);
const entry = (this.getManifest().plugins ?? [])
.find((plugin) => plugin.name.toLowerCase() === parsed.name.toLowerCase());
if (!entry) return null;
const resolvedVersion = resolveMarketplaceEntryVersion(entry, parsed.range);
if (!resolvedVersion) return null;
const registryEntry = toRegistryEntry(entry);
if (!registryEntry) return null;
return {
backendId: this.id,
backendKind: this.kind,
trust: this.trust,
entry: registryEntry,
version: {
version: resolvedVersion.version,
source: resolvedVersion.source,
ref: resolvedVersion.ref,
integrity: resolvedVersion.archiveIntegrity,
manifestDigest: resolvedVersion.manifestDigest,
deprecated: resolvedVersion.deprecated,
},
source: resolvedVersion.source,
ref: resolvedVersion.ref,
integrity: resolvedVersion.archiveIntegrity,
manifestDigest: resolvedVersion.manifestDigest,
};
}
async manifest(name: string, version: string): Promise<RegistryEntry | null> {
const resolved = await this.resolve(name, version);
return resolved?.entry ?? null;
}
async doctor(): Promise<RegistryDoctorReport> {
const issues = [];
const plugins = this.getManifest().plugins ?? [];
for (const entry of plugins) {
if (!/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/.test(entry.name)) {
issues.push({
severity: 'error' as const,
code: 'invalid-name',
message: 'Registry plugin name must be vendor/plugin-name.',
pluginName: entry.name,
});
}
if (!entry.source && !entry.dist?.archive) {
issues.push({
severity: 'error' as const,
code: 'missing-source',
message: 'Registry entry must provide source or dist.archive.',
pluginName: entry.name,
});
}
if (!entry.license) {
issues.push({
severity: 'warning' as const,
code: 'missing-license',
message: 'Registry entry should declare a license.',
pluginName: entry.name,
});
}
if (!entry.capabilitiesSummary || entry.capabilitiesSummary.length === 0) {
issues.push({
severity: 'warning' as const,
code: 'missing-capabilities',
message: 'Registry entry should summarize plugin capabilities.',
pluginName: entry.name,
});
}
if (entry.yanked && !entry.yankReason) {
issues.push({
severity: 'error' as const,
code: 'missing-yank-reason',
message: 'Yanked entries must keep a human-readable reason.',
pluginName: entry.name,
});
}
}
return {
ok: !issues.some((issue) => issue.severity === 'error'),
backendId: this.id,
checkedAt: Date.now(),
entriesChecked: plugins.length,
issues,
};
}
protected getManifest(): MarketplaceManifest {
return this.manifestData;
}
}
export function toRegistryEntry(entry: MarketplacePluginEntry): RegistryEntry | null {
const parsed = RegistryEntrySchema.safeParse({
...entry,
publisher: normalizePublisher(entry.publisher),
});
return parsed.success ? parsed.data : null;
}
function normalizePublisher(publisher: MarketplacePluginEntry['publisher']) {
if (!publisher) return undefined;
return {
id: publisher.id,
github: publisher.github,
url: publisher.url,
};
}

View file

@ -0,0 +1,126 @@
import type { MarketplacePluginEntry } from '@open-design/contracts';
export interface ParsedPluginSpecifier {
name: string;
range?: string;
}
export interface ResolvedMarketplaceVersion {
version: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
deprecated?: boolean | string;
}
export function parsePluginSpecifier(input: string): ParsedPluginSpecifier {
const trimmed = input.trim();
const slash = trimmed.indexOf('/');
const at = trimmed.lastIndexOf('@');
if (slash > 0 && at > slash + 1) {
const range = trimmed.slice(at + 1);
return range
? { name: trimmed.slice(0, at), range }
: { name: trimmed.slice(0, at) };
}
return { name: trimmed };
}
export function resolveMarketplaceEntryVersion(
entry: MarketplacePluginEntry,
requestedRange?: string,
): ResolvedMarketplaceVersion | null {
if (entry.yanked) return null;
const versions = entry.versions ?? [];
const range = requestedRange?.trim();
const defaultVersion =
entry.distTags?.latest ??
entry.version ??
versions.find((version) => !version.yanked)?.version;
const targetVersion = range && range !== 'latest'
? resolveRequestedVersion(versions, entry.distTags ?? {}, range)
: defaultVersion;
if (!targetVersion) return null;
const versionRecord = versions.find((version) => version.version === targetVersion);
if (versionRecord?.yanked) return null;
const source = versionRecord?.source ?? entry.source;
if (!source) return null;
const resolved: ResolvedMarketplaceVersion = {
version: targetVersion,
source,
};
const ref = versionRecord?.ref ?? entry.ref;
if (ref) resolved.ref = ref;
const manifestDigest =
versionRecord?.manifestDigest ??
versionRecord?.dist?.manifestDigest ??
entry.manifestDigest ??
entry.dist?.manifestDigest;
if (manifestDigest) resolved.manifestDigest = manifestDigest;
const archiveIntegrity =
versionRecord?.integrity ??
versionRecord?.dist?.integrity ??
entry.integrity ??
entry.dist?.integrity;
if (archiveIntegrity) resolved.archiveIntegrity = archiveIntegrity;
const deprecated = versionRecord?.deprecated ?? entry.deprecated;
if (deprecated !== undefined) resolved.deprecated = deprecated;
return resolved;
}
function resolveRequestedVersion(
versions: NonNullable<MarketplacePluginEntry['versions']>,
distTags: Record<string, string>,
range: string,
): string | null {
const tagged = distTags[range];
if (tagged) return tagged;
if (!range.startsWith('^') && !range.startsWith('~')) {
return range;
}
const base = parseSemver(range.slice(1));
if (!base) return null;
const candidates = versions
.filter((version) => !version.yanked)
.map((version) => version.version)
.filter((version) => {
const parsed = parseSemver(version);
if (!parsed) return false;
if (range.startsWith('^')) {
return parsed.major === base.major && compareSemver(parsed, base) >= 0;
}
return parsed.major === base.major &&
parsed.minor === base.minor &&
compareSemver(parsed, base) >= 0;
})
.sort((left, right) => compareSemver(parseSemver(right)!, parseSemver(left)!));
return candidates[0] ?? null;
}
interface SemverParts {
major: number;
minor: number;
patch: number;
}
function parseSemver(value: string): SemverParts | null {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(value);
if (!match) return null;
return {
major: Number(match[1] ?? 0),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
};
}
function compareSemver(left: SemverParts, right: SemverParts): number {
return left.major - right.major ||
left.minor - right.minor ||
left.patch - right.patch;
}

View file

@ -65,6 +65,7 @@ import {
MissingInputError,
pluginPromptBlock,
pruneExpiredSnapshots,
readPluginLockfile,
registerBuiltInAtomWorkers,
registerBundledPlugins,
resolvePluginSnapshot,
@ -1024,6 +1025,56 @@ const PROMPT_TEMPLATES_DIR = resolveDaemonResourceDir(
'prompt-templates',
path.join(PROJECT_ROOT, 'prompt-templates'),
);
const PLUGIN_REGISTRY_DIR = resolveDaemonResourceDir(
DAEMON_RESOURCE_ROOT,
'plugins/registry',
path.join(PROJECT_ROOT, 'plugins', 'registry'),
);
const OFFICIAL_MARKETPLACE_ID = 'official';
const OFFICIAL_MARKETPLACE_URL = 'https://open-design.ai/marketplace/open-design-marketplace.json';
const OFFICIAL_PLUGIN_SOURCE_REPO = 'github:nexu-io/open-design@main';
const DEFAULT_MARKETPLACE_SEED_BASE_URL = 'https://open-design.ai/marketplace';
const DEFAULT_MARKETPLACE_SEEDS = new Map([
[OFFICIAL_MARKETPLACE_ID, {
trust: 'official',
url: OFFICIAL_MARKETPLACE_URL,
}],
['community', {
trust: 'restricted',
url: `${DEFAULT_MARKETPLACE_SEED_BASE_URL}/community/open-design-marketplace.json`,
}],
]);
function bundledPluginRegistrySource(sourcePath) {
const rel = path.relative(PROJECT_ROOT, sourcePath).split(path.sep).join('/');
if (!rel || rel.startsWith('..')) return sourcePath;
return `${OFFICIAL_PLUGIN_SOURCE_REPO}/${rel}`;
}
function mergeMarketplaceEntries(manifestText, entries) {
try {
const parsed = JSON.parse(manifestText);
const plugins = Array.isArray(parsed.plugins) ? parsed.plugins : [];
const seen = new Set(plugins.map((entry) => String(entry?.name ?? '').toLowerCase()));
const generated = entries.filter((entry) => {
const key = String(entry.name ?? '').toLowerCase();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
});
return JSON.stringify({
...parsed,
metadata: {
...(parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : {}),
bundledPreinstallCount: entries.length,
},
plugins: [...plugins, ...generated],
});
} catch {
return manifestText;
}
}
export function resolveDataDir(raw, projectRoot) {
if (!raw) return path.join(projectRoot, '.od');
// expandHomePrefix is shared with media-config.ts so OD_DATA_DIR and
@ -1061,6 +1112,7 @@ export function resolveDataDir(raw, projectRoot) {
return resolved;
}
const RUNTIME_DATA_DIR = resolveDataDir(process.env.OD_DATA_DIR, PROJECT_ROOT);
const PLUGIN_LOCKFILE_PATH = path.join(RUNTIME_DATA_DIR, 'od-plugin-lock.json');
// Canonical (realpath-resolved) form of RUNTIME_DATA_DIR for the few callers
// that compare it against a user-supplied realpath() result. On macOS, /var
// is a symlink to /private/var, so an import realpath lands in /private/var
@ -2629,6 +2681,7 @@ export async function startServer({
console.log('[od] Codex plugins disabled via OD_CODEX_DISABLE_PLUGINS=1');
}
let bundledMarketplaceEntries = [];
// Plan §3.I3 / spec §23.3.5 — register every plugin under
// <projectRoot>/plugins/_official/** as a bundled plugin. The walker
// is idempotent (upserts on every boot) so a daemon upgrade rotates
@ -2638,7 +2691,26 @@ export async function startServer({
const result = await registerBundledPlugins({
db,
bundledRoot: defaultBundledRoot(PROJECT_ROOT),
marketplaceProvenance: {
sourceMarketplaceId: OFFICIAL_MARKETPLACE_ID,
marketplaceTrust: 'official',
entryNamePrefix: 'open-design',
},
});
bundledMarketplaceEntries = result.registered.map((plugin) => ({
name: `open-design/${plugin.id}`,
title: plugin.title,
description: plugin.description,
version: plugin.version,
source: bundledPluginRegistrySource(plugin.source),
publisher: { id: 'open-design', url: 'https://open-design.ai' },
homepage: plugin.manifest.homepage,
license: plugin.manifest.license,
tags: plugin.tags,
capabilitiesSummary: Array.isArray(plugin.manifest.od?.capabilities)
? plugin.manifest.od.capabilities
: undefined,
}));
if (result.registered.length > 0) {
console.log(`[plugins] registered ${result.registered.length} bundled plugin(s)`);
}
@ -2649,6 +2721,41 @@ export async function startServer({
console.warn(`[plugins] bundled registration failed: ${(err)?.message ?? err}`);
}
try {
const seedDirs = await fs.promises.readdir(PLUGIN_REGISTRY_DIR, { withFileTypes: true }).catch((err) => {
if (err?.code === 'ENOENT') return [];
throw err;
});
const { ensureMarketplaceManifest } = await import('./plugins/marketplaces.js');
for (const dirent of seedDirs) {
if (!dirent.isDirectory()) continue;
const id = dirent.name;
const manifestPath = path.join(PLUGIN_REGISTRY_DIR, id, 'open-design-marketplace.json');
if (!fs.existsSync(manifestPath)) continue;
let manifestText = await fs.promises.readFile(manifestPath, 'utf8');
if (id === OFFICIAL_MARKETPLACE_ID && bundledMarketplaceEntries.length > 0) {
manifestText = mergeMarketplaceEntries(manifestText, bundledMarketplaceEntries);
}
const configured = DEFAULT_MARKETPLACE_SEEDS.get(id) ?? {
trust: 'restricted',
url: `${DEFAULT_MARKETPLACE_SEED_BASE_URL}/${id}/open-design-marketplace.json`,
};
const result = ensureMarketplaceManifest(db, {
id,
url: configured.url,
trust: configured.trust,
manifestText,
});
if (result.ok) {
console.log(`[plugins] seeded ${id} registry source (${result.row.manifest.plugins.length} plugin(s))`);
} else {
console.warn(`[plugins] ${id} registry seed failed: ${result.message}`);
}
}
} catch (err) {
console.warn(`[plugins] registry seed failed: ${(err)?.message ?? err}`);
}
// Plan §3.A5 / spec §16 Phase 5 / PB2: periodic snapshot GC. Disabled
// when OD_SNAPSHOT_GC_INTERVAL_MS is 0; otherwise one-time bootstrap
// sweep + interval. The function returns a NOOP_HANDLE when disabled
@ -4098,6 +4205,7 @@ export async function startServer({
source,
_stagedFolder: pluginRoot,
_stagedSourceKind: 'user',
lockfilePath: PLUGIN_LOCKFILE_PATH,
})) {
if (ev.message) log.push(ev.message);
if (Array.isArray(ev.warnings)) warnings.splice(0, warnings.length, ...ev.warnings);
@ -4242,6 +4350,16 @@ export async function startServer({
app.post('/api/plugins/install', async (req, res) => {
const body = req.body && typeof req.body === 'object' ? req.body : {};
let source = typeof body.source === 'string' ? body.source : '';
let marketplaceResolution: {
marketplaceId: string;
marketplaceTrust: 'official' | 'trusted' | 'restricted';
pluginName: string;
pluginVersion: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
} | null = null;
if (!source) {
return res.status(400).json({ error: 'source is required' });
}
@ -4259,7 +4377,13 @@ export async function startServer({
// the same byte path that would happen if the user copy-pasted
// the source manually.
const { resolvePluginInMarketplaces } = await import('./plugins/marketplaces.js');
const resolved = resolvePluginInMarketplaces(db, source);
let lookupName = source;
const lockfile = await readPluginLockfile(PLUGIN_LOCKFILE_PATH);
const locked = lockfile.plugins[source];
if (locked?.version && !source.includes('@')) {
lookupName = `${source}@${locked.version}`;
}
const resolved = resolvePluginInMarketplaces(db, lookupName);
if (!resolved) {
return res.status(404).json({
error: {
@ -4269,6 +4393,7 @@ export async function startServer({
},
});
}
marketplaceResolution = resolved;
source = resolved.source;
}
@ -4282,7 +4407,18 @@ export async function startServer({
};
try {
for await (const ev of installPlugin(db, { source })) {
for await (const ev of installPlugin(db, {
source,
sourceMarketplaceId: marketplaceResolution?.marketplaceId,
sourceMarketplaceEntryName: marketplaceResolution?.pluginName,
sourceMarketplaceEntryVersion: marketplaceResolution?.pluginVersion,
marketplaceTrust: marketplaceResolution?.marketplaceTrust,
resolvedSource: marketplaceResolution?.source,
resolvedRef: marketplaceResolution?.ref,
manifestDigest: marketplaceResolution?.manifestDigest,
archiveIntegrity: marketplaceResolution?.archiveIntegrity,
lockfilePath: PLUGIN_LOCKFILE_PATH,
})) {
writeEvent(ev.kind, ev);
if (ev.kind === 'success' || ev.kind === 'error') break;
}
@ -4317,6 +4453,8 @@ export async function startServer({
// daemon's authoritative copy and confuse the next boot.
app.post('/api/plugins/:id/upgrade', async (req, res) => {
const id = req.params.id;
const body = req.body && typeof req.body === 'object' ? req.body : {};
const policy = body.policy === 'pinned' ? 'pinned' : 'latest';
const plugin = getInstalledPlugin(db, id);
if (!plugin) {
return res.status(404).json({
@ -4332,7 +4470,24 @@ export async function startServer({
},
});
}
const source = plugin.source;
let source = plugin.source;
let marketplaceResolution: {
marketplaceId: string;
marketplaceTrust: 'official' | 'trusted' | 'restricted';
pluginName: string;
pluginVersion: string;
source: string;
ref?: string;
manifestDigest?: string;
archiveIntegrity?: string;
} | null = null;
if (policy === 'latest' && plugin.sourceMarketplaceEntryName) {
const { resolvePluginInMarketplaces } = await import('./plugins/marketplaces.js');
marketplaceResolution = resolvePluginInMarketplaces(db, plugin.sourceMarketplaceEntryName);
if (marketplaceResolution) {
source = marketplaceResolution.source;
}
}
if (!source) {
return res.status(409).json({
error: {
@ -4352,10 +4507,22 @@ export async function startServer({
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source}` });
writeEvent('progress', { kind: 'progress', phase: 'resolving', message: `Upgrading ${id} from ${source} (policy=${policy})` });
try {
for await (const ev of installPlugin(db, { source, eventKind: 'upgraded' })) {
for await (const ev of installPlugin(db, {
source,
eventKind: 'upgraded',
sourceMarketplaceId: marketplaceResolution?.marketplaceId ?? plugin.sourceMarketplaceId,
sourceMarketplaceEntryName: marketplaceResolution?.pluginName ?? plugin.sourceMarketplaceEntryName,
sourceMarketplaceEntryVersion: marketplaceResolution?.pluginVersion ?? plugin.sourceMarketplaceEntryVersion,
marketplaceTrust: marketplaceResolution?.marketplaceTrust ?? plugin.marketplaceTrust,
resolvedSource: marketplaceResolution?.source ?? plugin.resolvedSource,
resolvedRef: marketplaceResolution?.ref ?? plugin.resolvedRef,
manifestDigest: marketplaceResolution?.manifestDigest ?? plugin.manifestDigest,
archiveIntegrity: marketplaceResolution?.archiveIntegrity ?? plugin.archiveIntegrity,
lockfilePath: PLUGIN_LOCKFILE_PATH,
})) {
writeEvent(ev.kind, ev);
if (ev.kind === 'success' || ev.kind === 'error') break;
}

View file

@ -64,6 +64,34 @@ describe('registerBundledPlugins', () => {
}
});
it('can stamp official registry provenance on bundled preinstalls', async () => {
const folder = path.join(tmpRoot, 'scenarios', 'starter');
await mkdir(folder, { recursive: true });
await writeFile(path.join(folder, 'open-design.json'), SAMPLE_MANIFEST('starter'));
await writeFile(path.join(folder, 'SKILL.md'), SAMPLE_SKILL('starter'));
const result = await registerBundledPlugins({
db,
bundledRoot: tmpRoot,
marketplaceProvenance: {
sourceMarketplaceId: 'official',
marketplaceTrust: 'official',
entryNamePrefix: 'open-design',
},
});
expect(result.registered[0]?.sourceKind).toBe('bundled');
expect(result.registered[0]?.sourceMarketplaceId).toBe('official');
expect(result.registered[0]?.sourceMarketplaceEntryName).toBe('open-design/starter');
expect(result.registered[0]?.sourceMarketplaceEntryVersion).toBe('0.1.0');
expect(result.registered[0]?.marketplaceTrust).toBe('official');
expect(result.registered[0]?.resolvedSource).toBe(folder);
const [row] = listInstalledPlugins(db);
expect(row?.sourceMarketplaceId).toBe('official');
expect(row?.sourceMarketplaceEntryName).toBe('open-design/starter');
});
it('also registers a direct <bundledRoot>/<plugin-id>/ folder', async () => {
// Direct layout (no tier): <bundledRoot>/sample-plugin/...
const folder = path.join(tmpRoot, 'sample-plugin');

View file

@ -14,6 +14,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import url from 'node:url';
import { createHash } from 'node:crypto';
import { Readable } from 'node:stream';
import { mkdtemp, rm, writeFile, mkdir, symlink, readdir } from 'node:fs/promises';
import Database from 'better-sqlite3';
@ -29,6 +30,7 @@ let pluginsRoot: string;
async function buildFixtureTarball(args: {
rootPrefix: string;
pluginSubpath?: string;
withSymlink?: boolean;
bigPaddingBytes?: number;
}): Promise<Buffer> {
@ -36,18 +38,21 @@ async function buildFixtureTarball(args: {
// codeload uses: `<repo>-<sha>/<files>`.
const tmp = await mkdtemp(path.join(os.tmpdir(), 'od-fixture-'));
const wrapper = path.join(tmp, args.rootPrefix);
await mkdir(wrapper, { recursive: true });
const pluginRoot = args.pluginSubpath
? path.join(wrapper, args.pluginSubpath)
: wrapper;
await mkdir(pluginRoot, { recursive: true });
const fixtureSrc = path.join(__dirname, 'fixtures', 'plugin-fixtures', 'sample-plugin');
for (const entry of await readdir(fixtureSrc)) {
const data = await fs.promises.readFile(path.join(fixtureSrc, entry));
await writeFile(path.join(wrapper, entry), data);
await writeFile(path.join(pluginRoot, entry), data);
}
if (args.withSymlink) {
await symlink('SKILL.md', path.join(wrapper, 'symlink-here'));
await symlink('SKILL.md', path.join(pluginRoot, 'symlink-here'));
}
if (args.bigPaddingBytes) {
const buf = Buffer.alloc(args.bigPaddingBytes, 0);
await writeFile(path.join(wrapper, 'huge.bin'), buf);
await writeFile(path.join(pluginRoot, 'huge.bin'), buf);
}
const stream = tarCreate(
{ cwd: tmp, gzip: true },
@ -111,6 +116,35 @@ describe('archive installer', () => {
expect(row).toEqual({ source_kind: 'github', source: 'github:open-design/sample-plugin' });
});
it('extracts a github source with a ref and plugin subpath', async () => {
const tarball = await buildFixtureTarball({
rootPrefix: 'open-design-garnet-hemisphere',
pluginSubpath: path.join('plugins', 'community', 'registry-starter'),
});
let urlSeen = '';
const fetcher: ArchiveFetcher = async (u) => {
urlSeen = u;
return makeFetcher(tarball)('');
};
let success = false;
let error: string | undefined;
const source = 'github:nexu-io/open-design@garnet-hemisphere/plugins/community/registry-starter';
for await (const ev of installPlugin(db, {
source,
roots: { userPluginsRoot: pluginsRoot },
fetcher,
})) {
if (ev.kind === 'success') success = true;
if (ev.kind === 'error') error = ev.message;
}
if (!success) {
throw new Error(`install failed: ${error}`);
}
expect(urlSeen).toBe('https://codeload.github.com/nexu-io/open-design/tar.gz/garnet-hemisphere');
const row = db.prepare(`SELECT source_kind, source FROM installed_plugins WHERE id = 'sample-plugin'`).get();
expect(row).toEqual({ source_kind: 'github', source });
});
it('extracts a https://*.tgz source (records source_kind=url)', async () => {
const tarball = await buildFixtureTarball({ rootPrefix: 'sample-plugin-1.0.0' });
let success = false;
@ -122,13 +156,35 @@ describe('archive installer', () => {
if (ev.kind === 'success') success = true;
}
expect(success).toBe(true);
const row = db.prepare(`SELECT source_kind, source FROM installed_plugins WHERE id = 'sample-plugin'`).get();
const row = db.prepare(`SELECT source_kind, source, archive_integrity FROM installed_plugins WHERE id = 'sample-plugin'`).get() as {
source_kind: string;
source: string;
archive_integrity: string;
};
expect(row).toEqual({
source_kind: 'url',
source: 'https://example.com/sample-plugin-1.0.0.tgz',
archive_integrity: `sha256:${createHash('sha256').update(tarball).digest('hex')}`,
});
});
it('rejects archive downloads when marketplace integrity does not match', async () => {
const tarball = await buildFixtureTarball({ rootPrefix: 'sample-plugin-1.0.0' });
let success = false;
let error: string | undefined;
for await (const ev of installPlugin(db, {
source: 'https://example.com/sample-plugin-1.0.0.tgz',
roots: { userPluginsRoot: pluginsRoot },
fetcher: makeFetcher(tarball),
archiveIntegrity: 'sha256:deadbeef',
})) {
if (ev.kind === 'success') success = true;
if (ev.kind === 'error') error = ev.message;
}
expect(success).toBe(false);
expect(error).toMatch(/integrity mismatch/);
});
it('rejects archives that exceed the size cap', async () => {
const tarball = await buildFixtureTarball({
rootPrefix: 'sample-plugin-fat',

View file

@ -4,13 +4,14 @@
// arrival lands in Phase 2A.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migratePlugins } from '../src/plugins/persistence.js';
import { installFromLocalFolder, uninstallPlugin } from '../src/plugins/installer.js';
import { installFromLocalFolder, installPlugin, uninstallPlugin } from '../src/plugins/installer.js';
import { listInstalledPlugins } from '../src/plugins/registry.js';
import { addMarketplace, resolvePluginInMarketplaces } from '../src/plugins/marketplaces.js';
import type { InstalledPluginRecord } from '@open-design/contracts';
let tmpRoot: string;
@ -70,6 +71,8 @@ describe('installFromLocalFolder', () => {
const list = listInstalledPlugins(db);
expect(list).toHaveLength(1);
expect(list[0]?.id).toBe('sample-plugin');
expect(list[0]?.sourceKind).toBe('local');
expect(list[0]?.trust).toBe('restricted');
expect(list[0]?.fsPath).toBe(path.join(pluginsRoot, 'sample-plugin'));
});
@ -102,4 +105,132 @@ describe('installFromLocalFolder', () => {
expect(result.ok).toBe(true);
expect(listInstalledPlugins(db)).toHaveLength(0);
});
it('persists marketplace provenance and inherited trust for resolved installs', async () => {
const lockfilePath = path.join(tmpRoot, '.od', 'od-plugin-lock.json');
const manifest = JSON.stringify({
specVersion: '1.0.0',
name: 'fixture-registry',
version: '1.0.0',
plugins: [
{
name: 'vendor/sample-plugin',
title: 'Sample Plugin',
source: sourceFolder,
version: '1.0.0',
ref: 'abc123',
integrity: 'sha512-fixture',
manifestDigest: 'sha256-manifest',
},
],
});
const added = await addMarketplace(db, {
url: 'https://example.com/open-design-marketplace.json',
trust: 'official',
fetcher: async () => ({
ok: true,
status: 200,
text: async () => manifest,
}),
});
if (!added.ok) throw new Error('marketplace setup failed');
const resolved = resolvePluginInMarketplaces(db, 'vendor/sample-plugin');
expect(resolved).not.toBeNull();
let installedRecord: InstalledPluginRecord | null = null;
for await (const ev of installPlugin(db, {
source: resolved!.source,
roots: { userPluginsRoot: pluginsRoot },
sourceMarketplaceId: resolved!.marketplaceId,
sourceMarketplaceEntryName: resolved!.pluginName,
sourceMarketplaceEntryVersion: resolved!.pluginVersion,
marketplaceTrust: resolved!.marketplaceTrust,
resolvedSource: resolved!.source,
resolvedRef: resolved!.ref!,
manifestDigest: resolved!.manifestDigest!,
archiveIntegrity: resolved!.archiveIntegrity!,
lockfilePath,
})) {
if (ev.kind === 'success') installedRecord = ev.plugin;
if (ev.kind === 'error') throw new Error(ev.message);
}
expect(installedRecord?.id).toBe('sample-plugin');
expect(installedRecord?.sourceKind).toBe('local');
expect(installedRecord?.sourceMarketplaceId).toBe(added.row.id);
expect(installedRecord?.sourceMarketplaceEntryName).toBe('vendor/sample-plugin');
expect(installedRecord?.sourceMarketplaceEntryVersion).toBe('1.0.0');
expect(installedRecord?.marketplaceTrust).toBe('official');
expect(installedRecord?.trust).toBe('trusted');
expect(installedRecord?.resolvedSource).toBe(sourceFolder);
expect(installedRecord?.resolvedRef).toBe('abc123');
expect(installedRecord?.manifestDigest).toBe('sha256-manifest');
expect(installedRecord?.archiveIntegrity).toBe('sha512-fixture');
const [row] = listInstalledPlugins(db);
expect(row?.sourceMarketplaceId).toBe(added.row.id);
expect(row?.marketplaceTrust).toBe('official');
expect(row?.trust).toBe('trusted');
const lockfile = JSON.parse(await readFile(lockfilePath, 'utf8'));
expect(lockfile.plugins['vendor/sample-plugin']).toMatchObject({
name: 'vendor/sample-plugin',
version: '1.0.0',
sourceMarketplaceId: added.row.id,
sourceMarketplaceEntryName: 'vendor/sample-plugin',
resolvedRef: 'abc123',
manifestDigest: 'sha256-manifest',
archiveIntegrity: 'sha512-fixture',
});
});
it('keeps restricted marketplace installs restricted', async () => {
const manifest = JSON.stringify({
specVersion: '1.0.0',
name: 'restricted-registry',
version: '1.0.0',
plugins: [
{
name: 'vendor/sample-plugin',
title: 'Sample Plugin',
source: sourceFolder,
version: '1.0.0',
},
],
});
const added = await addMarketplace(db, {
url: 'https://example.com/restricted-marketplace.json',
trust: 'restricted',
fetcher: async () => ({
ok: true,
status: 200,
text: async () => manifest,
}),
});
if (!added.ok) throw new Error('marketplace setup failed');
const resolved = resolvePluginInMarketplaces(db, 'vendor/sample-plugin');
expect(resolved).not.toBeNull();
let installedRecord: InstalledPluginRecord | null = null;
for await (const ev of installPlugin(db, {
source: resolved!.source,
roots: { userPluginsRoot: pluginsRoot },
sourceMarketplaceId: resolved!.marketplaceId,
sourceMarketplaceEntryName: resolved!.pluginName,
sourceMarketplaceEntryVersion: resolved!.pluginVersion,
marketplaceTrust: resolved!.marketplaceTrust,
resolvedSource: resolved!.source,
})) {
if (ev.kind === 'success') installedRecord = ev.plugin;
if (ev.kind === 'error') throw new Error(ev.message);
}
expect(installedRecord?.sourceMarketplaceId).toBe(added.row.id);
expect(installedRecord?.marketplaceTrust).toBe('restricted');
expect(installedRecord?.trust).toBe('restricted');
const [row] = listInstalledPlugins(db);
expect(row?.marketplaceTrust).toBe('restricted');
expect(row?.trust).toBe('restricted');
});
});

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import type { InstalledPluginRecord } from '@open-design/contracts';
import {
lockEntryFromInstalled,
readPluginLockfile,
upsertPluginLockfileEntry,
} from '../src/plugins/lockfile.js';
const plugin: InstalledPluginRecord = {
id: 'registry-starter',
title: 'Registry starter',
version: '0.1.0',
sourceKind: 'github',
source: 'github:nexu-io/open-design@main/plugins/community/registry-starter',
sourceMarketplaceId: 'community',
sourceMarketplaceEntryName: 'community/registry-starter',
sourceMarketplaceEntryVersion: '0.1.0',
marketplaceTrust: 'restricted',
resolvedSource: 'github:nexu-io/open-design@main/plugins/community/registry-starter',
resolvedRef: 'main',
manifestDigest: 'sha256:manifest',
archiveIntegrity: 'sha256:archive',
trust: 'restricted',
capabilitiesGranted: ['prompt:inject'],
manifest: {
specVersion: '1.0.0',
name: 'registry-starter',
version: '0.1.0',
title: 'Registry starter',
},
fsPath: '/tmp/registry-starter',
installedAt: 1,
updatedAt: 1,
};
describe('plugin lockfile', () => {
it('converts installed provenance into a reproducible lock entry', () => {
expect(lockEntryFromInstalled(plugin, 123)).toMatchObject({
name: 'community/registry-starter',
version: '0.1.0',
sourceMarketplaceId: 'community',
resolvedRef: 'main',
manifestDigest: 'sha256:manifest',
archiveIntegrity: 'sha256:archive',
lockedAt: 123,
});
});
it('writes stable .od/od-plugin-lock.json content', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'od-lock-'));
try {
const filePath = path.join(dir, '.od', 'od-plugin-lock.json');
await upsertPluginLockfileEntry(filePath, plugin, 123);
expect(await readPluginLockfile(filePath)).toMatchObject({
schemaVersion: 1,
plugins: {
'community/registry-starter': {
source: plugin.source,
archiveIntegrity: 'sha256:archive',
},
},
});
expect(await readFile(filePath, 'utf8')).toContain('"schemaVersion": 1');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { doctorMarketplace } from '../src/plugins/marketplace-doctor.js';
describe('marketplace doctor', () => {
it('reports registry-grade issues for catalogs', async () => {
const report = await doctorMarketplace({
id: 'community',
trust: 'restricted',
checkedAt: 123,
strict: true,
manifest: {
specVersion: '1.0.0',
name: 'community',
version: '1.0.0',
plugins: [
{
name: 'bad-flat-name',
version: '0.1.0',
source: 'github:example/bad',
dist: { archive: 'https://example.com/bad.tgz' },
},
{
name: 'vendor/good',
version: '1.0.0',
source: 'github:vendor/good',
license: 'MIT',
capabilitiesSummary: ['prompt:inject'],
publisher: { id: 'vendor' },
},
],
},
});
expect(report.ok).toBe(false);
expect(report.checkedAt).toBe(123);
expect(report.issues.map((issue) => issue.code)).toEqual(
expect.arrayContaining([
'invalid-name',
'archive-integrity-required',
'missing-license',
'missing-capabilities',
'missing-publisher',
]),
);
});
});

View file

@ -6,13 +6,14 @@
// will read against.
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, rm } from 'node:fs/promises';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import Database from 'better-sqlite3';
import { migratePlugins } from '../src/plugins/persistence.js';
import {
addMarketplace,
ensureMarketplaceManifest,
getMarketplace,
listMarketplaces,
refreshMarketplace,
@ -74,6 +75,44 @@ describe('marketplaces', () => {
expect(listMarketplaces(db)).toHaveLength(1);
});
it('resolves marketplace names with exact versions, dist-tags, ranges, and yanks', async () => {
const manifest = JSON.stringify({
specVersion: '1.0.0',
name: 'versions',
version: '1.0.0',
plugins: [
{
name: 'vendor/ranged',
source: 'github:vendor/ranged@v1.2.0/plugin',
version: '1.2.0',
distTags: { latest: '1.2.0', beta: '2.0.0' },
versions: [
{ version: '1.0.0', source: 'github:vendor/ranged@v1.0.0/plugin', integrity: 'sha256:one' },
{ version: '1.1.0', source: 'github:vendor/ranged@v1.1.0/plugin', integrity: 'sha256:two' },
{ version: '1.2.0', source: 'github:vendor/ranged@v1.2.0/plugin', integrity: 'sha256:three' },
{ version: '2.0.0', source: 'github:vendor/ranged@v2.0.0/plugin', yanked: true },
],
},
],
});
const seeded = ensureMarketplaceManifest(db, {
id: 'versions',
url: 'https://example.com/versions.json',
trust: 'trusted',
manifestText: manifest,
});
if (!seeded.ok) throw new Error('seed failed');
expect(resolvePluginInMarketplaces(db, 'vendor/ranged')?.pluginVersion).toBe('1.2.0');
expect(resolvePluginInMarketplaces(db, 'vendor/ranged@1.0.0')).toMatchObject({
pluginVersion: '1.0.0',
source: 'github:vendor/ranged@v1.0.0/plugin',
archiveIntegrity: 'sha256:one',
});
expect(resolvePluginInMarketplaces(db, 'vendor/ranged@^1.0.0')?.pluginVersion).toBe('1.2.0');
expect(resolvePluginInMarketplaces(db, 'vendor/ranged@beta')).toBeNull();
});
it('addMarketplace rejects non-https urls', async () => {
const result = await addMarketplace(db, {
url: 'http://example.com/marketplace.json',
@ -97,6 +136,19 @@ describe('marketplaces', () => {
}
});
it('requires a raw open-design-marketplace.json document, not a GitHub tree page', async () => {
const result = await addMarketplace(db, {
url: 'https://github.com/nexu-io/open-design/tree/garnet-hemisphere/plugins/registry/community',
fetcher: fixtureFetcher('<!doctype html><html><body>GitHub tree page</body></html>'),
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(422);
expect(result.message).toMatch(/validation/i);
}
});
it('refresh re-fetches and updates refreshed_at', async () => {
const added = await addMarketplace(db, {
url: 'https://example.com/marketplace.json',
@ -132,6 +184,122 @@ describe('marketplaces', () => {
expect(removeMarketplace(db, added.row.id)).toBe(true);
expect(getMarketplace(db, added.row.id)).toBeNull();
});
it('upserts a fixed built-in marketplace manifest', () => {
const result = ensureMarketplaceManifest(db, {
id: 'official',
url: 'https://open-design.ai/marketplace/open-design-marketplace.json',
trust: 'official',
manifestText: VALID_MANIFEST,
now: 123,
});
if (!result.ok) throw new Error('seed failed');
expect(result.row.id).toBe('official');
expect(result.row.trust).toBe('official');
const updatedManifest = JSON.stringify({
specVersion: '1.0.0',
name: 'test-marketplace',
version: '1.0.1',
plugins: [],
});
const updated = ensureMarketplaceManifest(db, {
id: 'official',
url: 'https://open-design.ai/marketplace/open-design-marketplace.json',
trust: 'official',
manifestText: updatedManifest,
now: 456,
});
if (!updated.ok) throw new Error('update failed');
expect(listMarketplaces(db)).toHaveLength(1);
expect(updated.row.addedAt).toBe(123);
expect(updated.row.refreshedAt).toBe(456);
expect(updated.row.version).toBe('1.0.1');
});
it('seeds the checked-in default community registry as restricted and resolvable', async () => {
const communityManifest = await readFile(
new URL('../../../plugins/registry/community/open-design-marketplace.json', import.meta.url),
'utf8',
);
const seeded = ensureMarketplaceManifest(db, {
id: 'community',
url: 'https://open-design.ai/marketplace/community/open-design-marketplace.json',
trust: 'restricted',
manifestText: communityManifest,
now: 123,
});
if (!seeded.ok) throw new Error('community seed failed');
expect(seeded.row.trust).toBe('restricted');
const resolved = resolvePluginInMarketplaces(db, 'community/registry-starter');
expect(resolved?.marketplaceId).toBe('community');
expect(resolved?.marketplaceTrust).toBe('restricted');
expect(resolved?.source).toMatch(
/^github:nexu-io\/open-design(?:@[^/]+)?\/plugins\/community\/registry-starter$/,
);
});
it('keeps the checked-in official registry populated from bundled plugins', async () => {
const officialManifestText = await readFile(
new URL('../../../plugins/registry/official/open-design-marketplace.json', import.meta.url),
'utf8',
);
const officialManifest = JSON.parse(officialManifestText) as {
trust?: string;
metadata?: { bundledPreinstallCount?: number };
plugins?: Array<{ name?: string; source?: string }>;
};
expect(officialManifest.trust).toBe('official');
expect(officialManifest.plugins?.length).toBeGreaterThan(100);
expect(officialManifest.metadata?.bundledPreinstallCount).toBe(
officialManifest.plugins?.length,
);
expect(officialManifest.plugins?.some((plugin) => plugin.name === 'open-design/build-test')).toBe(true);
expect(officialManifest.plugins?.every((plugin) =>
/^github:nexu-io\/open-design(?:@[^/]+)?\/plugins\/_official\//.test(plugin.source ?? ''),
)).toBe(true);
const seeded = ensureMarketplaceManifest(db, {
id: 'official',
url: 'https://open-design.ai/marketplace/open-design-marketplace.json',
trust: 'official',
manifestText: officialManifestText,
now: 123,
});
if (!seeded.ok) throw new Error('official seed failed');
const resolved = resolvePluginInMarketplaces(db, 'open-design/build-test');
expect(resolved?.marketplaceId).toBe('official');
expect(resolved?.marketplaceTrust).toBe('official');
});
it('keeps checked-in community registry entries pointed at source folders that can pack', async () => {
const communityManifest = JSON.parse(await readFile(
new URL('../../../plugins/registry/community/open-design-marketplace.json', import.meta.url),
'utf8',
)) as {
plugins?: Array<{ name?: string; source?: string }>;
};
const entry = communityManifest.plugins?.find((plugin) => plugin.name === 'community/registry-starter');
expect(entry?.source).toBeTruthy();
const sourceSubpath = entry!.source!.replace(/^github:nexu-io\/open-design(?:@[^/]+)?\//, '');
expect(sourceSubpath).toBe('plugins/community/registry-starter');
const sourceManifest = await readFile(
new URL(`../../../${sourceSubpath}/open-design.json`, import.meta.url),
'utf8',
);
expect(JSON.parse(sourceManifest)).toMatchObject({
name: 'community-registry-starter',
plugin: {
repo: expect.stringContaining('github.com/nexu-io/open-design'),
},
});
});
});
describe('resolvePluginInMarketplaces', () => {

View file

@ -10,10 +10,11 @@ import {
buildPublishLink,
PublishError,
PUBLISH_TARGETS,
upsertMarketplaceJsonEntry,
} from '../src/plugins/publish.js';
const META = {
pluginId: 'sample-plugin',
pluginId: 'open-design/sample-plugin',
pluginVersion: '1.0.0',
pluginTitle: 'Sample Plugin',
pluginDescription: 'A fixture for the publish flow.',
@ -26,6 +27,7 @@ describe('buildPublishLink', () => {
'anthropics-skills',
'awesome-agent-skills',
'clawhub',
'open-design',
'skills-sh',
].sort());
});
@ -57,6 +59,13 @@ describe('buildPublishLink', () => {
expect(link.prBody).toContain('npx skills add open-design/sample-plugin');
});
it('builds an Open Design registry submission URL', () => {
const link = buildPublishLink({ catalog: 'open-design', meta: META });
expect(link.catalogLabel).toBe('open-design/plugin-registry');
expect(link.url).toMatch(/^https:\/\/github\.com\/open-design\/plugin-registry\/issues\/new\?/);
expect(link.prBody).toContain('open-design-marketplace.json');
});
it('falls back to owner/repo placeholder when repoUrl is missing for skills-sh', () => {
const link = buildPublishLink({
catalog: 'skills-sh',
@ -69,3 +78,76 @@ describe('buildPublishLink', () => {
expect(() => buildPublishLink({ catalog: 'mystery' as never, meta: META })).toThrow(PublishError);
});
});
describe('upsertMarketplaceJsonEntry', () => {
it('adds a namespaced plugin entry with a reproducible github source', () => {
const outcome = upsertMarketplaceJsonEntry({
generatedAt: '2026-05-14T00:00:00.000Z',
manifest: {
specVersion: '1.0.0',
name: 'community',
version: '0.1.0',
plugins: [],
},
meta: META,
});
expect(outcome.inserted).toBe(true);
expect(outcome.entry).toMatchObject({
name: 'open-design/sample-plugin',
source: 'github:open-design/sample-plugin',
version: '1.0.0',
title: 'Sample Plugin',
publisher: {
github: 'open-design',
},
});
expect(outcome.manifest.plugins).toHaveLength(1);
expect(outcome.manifest.generatedAt).toBe('2026-05-14T00:00:00.000Z');
});
it('updates existing entries and preserves unrelated catalog metadata', () => {
const outcome = upsertMarketplaceJsonEntry({
generatedAt: '2026-05-14T00:00:00.000Z',
manifest: {
specVersion: '1.0.0',
name: 'community',
version: '0.1.0',
extra: true,
plugins: [
{
name: 'open-design/sample-plugin',
source: 'github:open-design/sample-plugin@old',
version: '0.9.0',
tags: ['kept'],
},
],
},
meta: {
...META,
pluginVersion: '1.1.0',
repoUrl: 'https://github.com/open-design/sample-plugin/tree/main/plugins/sample',
},
});
expect(outcome.inserted).toBe(false);
expect(outcome.manifest.extra).toBe(true);
expect(outcome.manifest.plugins[0]).toMatchObject({
name: 'open-design/sample-plugin',
source: 'github:open-design/sample-plugin@main/plugins/sample',
version: '1.1.0',
tags: ['kept'],
});
});
it('rejects flat ids for public marketplace JSON', () => {
expect(() => upsertMarketplaceJsonEntry({
manifest: { plugins: [] },
meta: {
pluginId: 'sample-plugin',
pluginVersion: '1.0.0',
repoUrl: 'https://github.com/open-design/sample-plugin',
},
})).toThrow(PublishError);
});
});

View file

@ -69,12 +69,27 @@ const baseInput = (extra: Partial<Parameters<typeof createSnapshot>[1]> = {}) =>
describe('snapshots writer', () => {
it('createSnapshot inserts a row and round-trips via getSnapshot', () => {
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run('project-1', 'Project 1');
const snap = createSnapshot(db, baseInput());
const snap = createSnapshot(db, baseInput({
sourceMarketplaceId: 'official',
sourceMarketplaceEntryName: 'open-design/sample-plugin',
sourceMarketplaceEntryVersion: '1.0.0',
marketplaceTrust: 'official',
resolvedSource: 'github:open-design/plugins@abc123/sample-plugin',
resolvedRef: 'abc123',
archiveIntegrity: 'sha512-fixture',
}));
expect(snap.snapshotId).toMatch(/^[0-9a-f-]{36}$/);
const fetched = getSnapshot(db, snap.snapshotId);
expect(fetched).not.toBeNull();
expect(fetched!.pluginId).toBe('sample-plugin');
expect(fetched!.manifestSourceDigest).toBe('digest-1');
expect(fetched!.sourceMarketplaceId).toBe('official');
expect(fetched!.sourceMarketplaceEntryName).toBe('open-design/sample-plugin');
expect(fetched!.sourceMarketplaceEntryVersion).toBe('1.0.0');
expect(fetched!.marketplaceTrust).toBe('official');
expect(fetched!.resolvedSource).toBe('github:open-design/plugins@abc123/sample-plugin');
expect(fetched!.resolvedRef).toBe('abc123');
expect(fetched!.archiveIntegrity).toBe('sha512-fixture');
expect(fetched!.status).toBe('fresh');
});

View file

@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import type { MarketplaceManifest } from '@open-design/contracts';
import { StaticRegistryBackend } from '../src/registry/static-backend.js';
import {
DatabaseRegistryBackend,
ensureRegistryTables,
upsertRegistryEntry,
} from '../src/registry/database-backend.js';
import { GithubRegistryBackend, type GithubRegistryClient } from '../src/registry/github-backend.js';
const manifest: MarketplaceManifest = {
specVersion: '1.0.0',
name: 'fixture',
version: '1.0.0',
plugins: [
{
name: 'vendor/example',
title: 'Example',
description: 'Searchable fixture plugin',
version: '1.1.0',
source: 'github:vendor/example@v1.1.0/plugin',
versions: [
{
version: '1.0.0',
source: 'github:vendor/example@v1.0.0/plugin',
integrity: 'sha256:old',
},
{
version: '1.1.0',
source: 'github:vendor/example@v1.1.0/plugin',
integrity: 'sha256:new',
},
],
distTags: { latest: '1.1.0' },
license: 'MIT',
capabilitiesSummary: ['prompt:inject'],
tags: ['fixture'],
},
],
};
describe('registry backends', () => {
it('resolves exact versions and dist-tags from static manifests', async () => {
const backend = new StaticRegistryBackend({
id: 'fixture',
trust: 'trusted',
manifest,
});
await expect(backend.resolve('vendor/example')).resolves.toMatchObject({
source: 'github:vendor/example@v1.1.0/plugin',
integrity: 'sha256:new',
});
await expect(backend.resolve('vendor/example@1.0.0')).resolves.toMatchObject({
source: 'github:vendor/example@v1.0.0/plugin',
integrity: 'sha256:old',
});
});
it('keeps database backend behavior equivalent to static backend', async () => {
const db = new Database(':memory:');
try {
ensureRegistryTables(db);
const staticBackend = new StaticRegistryBackend({
id: 'fixture',
trust: 'restricted',
manifest,
});
for (const entry of await staticBackend.list()) {
upsertRegistryEntry(db, 'fixture', entry, 123);
}
const databaseBackend = new DatabaseRegistryBackend({ id: 'fixture', db });
await expect(databaseBackend.list()).resolves.toEqual(await staticBackend.list());
await expect(databaseBackend.search({ query: 'Searchable' })).resolves.toMatchObject([
{ entry: { name: 'vendor/example' } },
]);
await expect(databaseBackend.resolve('vendor/example')).resolves.toMatchObject({
source: 'github:vendor/example@v1.1.0/plugin',
});
} finally {
db.close();
}
});
it('builds GitHub publish PR mutations with deterministic paths', async () => {
let mutationFiles: string[] = [];
const client: GithubRegistryClient = {
async readMarketplace() {
return manifest;
},
async createPublishPullRequest(mutation) {
mutationFiles = mutation.files.map((file) => file.path);
return { url: 'https://github.com/open-design/plugin-registry/pull/1' };
},
};
const backend = await GithubRegistryBackend.create({
id: 'official',
owner: 'open-design',
repo: 'plugin-registry',
client,
});
const entry = (await backend.list())[0];
if (!entry) throw new Error('fixture entry missing');
await expect(backend.publish?.({ entry })).resolves.toMatchObject({
ok: true,
pullRequestUrl: 'https://github.com/open-design/plugin-registry/pull/1',
});
expect(mutationFiles).toEqual([
'plugins/vendor/example/entry.json',
'plugins/vendor/example/versions/1.1.0.json',
]);
});
});

View file

@ -2051,3 +2051,766 @@ footer {
.work-card { padding: 26px 22px; }
.work-card.alt { padding: 24px 20px; }
}
/* ====== Plugin registry pages ====== */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.plugin-shell .nav { border-bottom: 1px solid var(--line-soft); }
.plugin-directory,
.plugin-detail { padding-bottom: 96px; }
.plugin-hero,
.plugin-detail-hero {
padding: 96px 0 72px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(247, 241, 222, 0.74), rgba(239, 231, 210, 0.4)),
var(--paper);
}
.plugin-hero__grid,
.plugin-detail-hero__grid {
display: grid;
gap: 56px;
align-items: start;
}
.plugin-hero__grid {
grid-template-columns: minmax(0, 1fr) minmax(360px, 440px);
}
.plugin-detail-hero__grid {
grid-template-columns: minmax(0, 1fr) minmax(380px, 520px);
}
.plugin-hero h1,
.plugin-detail h1 {
max-width: 900px;
margin-top: 18px;
font-family: var(--serif);
font-size: clamp(48px, 7vw, 96px);
line-height: 0.94;
letter-spacing: 0;
}
.plugin-hero p,
.plugin-detail-hero p,
.plugin-detail-panel p {
max-width: 760px;
margin-top: 24px;
color: var(--ink-mute);
font-size: 18px;
line-height: 1.65;
}
.plugin-hero__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 34px;
}
.plugin-hero__panel,
.plugin-detail-preview,
.plugin-detail__facts,
.plugin-detail-panel {
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(247, 241, 222, 0.72);
box-shadow: var(--shadow);
}
.plugin-hero__panel {
display: grid;
gap: 12px;
padding: 14px;
overflow: hidden;
}
.plugin-showcase__head {
min-height: 116px;
padding: 18px;
background: rgba(255, 255, 255, 0.18);
border-bottom: 1px solid var(--line-soft);
}
.plugin-showcase__head span,
.plugin-showcase__head small,
.plugin-showcase-item small,
.plugin-showcase-item em {
display: block;
color: var(--ink-mute);
font: 700 10px var(--body);
text-transform: uppercase;
letter-spacing: 0.1em;
}
.plugin-showcase__head strong {
display: block;
font-family: var(--serif);
font-size: 56px;
font-weight: 500;
line-height: 0.86;
}
.plugin-showcase__head small {
margin-top: 10px;
}
.plugin-showcase__list {
display: grid;
gap: 10px;
}
.plugin-showcase-item {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 12px;
align-items: center;
min-height: 88px;
padding: 10px;
border: 1px solid var(--line-soft);
border-radius: 8px;
background: rgba(255, 255, 255, 0.18);
color: inherit;
text-decoration: none;
}
.plugin-showcase-item__visual {
position: relative;
display: block;
aspect-ratio: 16 / 10;
overflow: hidden;
border: 1px solid rgba(21, 20, 15, 0.14);
border-radius: 6px;
background: #101b38;
}
.plugin-showcase-item__visual img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.plugin-showcase-item__visual > span {
position: absolute;
display: block;
background: rgba(247, 241, 222, 0.86);
}
.plugin-showcase-item__visual > span:nth-child(1) {
left: 12%;
top: 22%;
width: 58%;
height: 10px;
}
.plugin-showcase-item__visual > span:nth-child(2) {
left: 12%;
top: 46%;
width: 72%;
height: 5px;
opacity: 0.72;
}
.plugin-showcase-item__visual > span:nth-child(3) {
left: 12%;
bottom: 18%;
width: 36%;
height: 5px;
opacity: 0.56;
}
.plugin-showcase-item__copy {
min-width: 0;
}
.plugin-showcase-item strong {
display: block;
margin-top: 3px;
font-family: var(--serif);
font-size: 22px;
font-weight: 600;
line-height: 1.02;
overflow-wrap: anywhere;
}
.plugin-showcase-item em {
margin-top: 7px;
font-family: var(--mono);
font-style: normal;
text-transform: none;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.plugin-showcase-item--image .plugin-showcase-item__visual { background: #d9b86f; }
.plugin-showcase-item--video .plugin-showcase-item__visual { background: #16120f; }
.plugin-showcase-item--dashboard .plugin-showcase-item__visual { background: #eef3ed; }
.plugin-showcase-item--system .plugin-showcase-item__visual { background: #243025; }
.plugin-showcase-item--atom .plugin-showcase-item__visual { background: #f2e8c9; }
.plugin-hero__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--line-soft);
border-radius: 8px;
}
.plugin-hero__stats div {
min-width: 0;
padding: 14px;
background: rgba(255, 255, 255, 0.16);
}
.plugin-hero__stats dt {
font-family: var(--serif);
font-size: 30px;
line-height: 0.95;
}
.plugin-hero__stats dd {
margin-top: 8px;
color: var(--ink-mute);
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-registry-section,
.plugin-detail-section {
padding: 64px 0 0;
}
.plugin-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
gap: 24px;
align-items: end;
}
.plugin-toolbar h2,
.plugin-detail-panel h2 {
margin-top: 8px;
font-family: var(--serif);
font-size: clamp(34px, 4vw, 54px);
line-height: 1;
letter-spacing: 0;
}
.plugin-search input {
width: 100%;
min-height: 54px;
padding: 0 18px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(247, 241, 222, 0.86);
color: var(--ink);
font: 500 15px var(--body);
outline: none;
}
.plugin-search input:focus {
border-color: rgba(237, 111, 92, 0.72);
box-shadow: 0 0 0 3px rgba(237, 111, 92, 0.16);
}
.plugin-filter-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 28px;
}
.plugin-filter {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(247, 241, 222, 0.65);
color: var(--ink-mute);
font: 600 14px var(--body);
cursor: pointer;
}
.plugin-filter span { color: var(--ink-faint); }
.plugin-filter.is-active {
background: var(--ink);
color: var(--bone);
border-color: var(--ink);
}
.plugin-filter.is-active span { color: rgba(247, 241, 222, 0.72); }
.plugin-result-count {
margin-top: 18px;
color: var(--ink-mute);
font-size: 14px;
}
.plugin-card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 16px;
margin-top: 24px;
}
.plugin-card-grid.compact { margin-top: 22px; }
.plugin-card-grid.compact .plugin-card {
min-height: 280px;
padding: 20px;
}
.plugin-card {
min-height: 430px;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
padding: 14px 16px 16px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(247, 241, 222, 0.66);
box-shadow: 0 18px 36px -30px rgba(21, 20, 15, 0.22);
overflow: hidden;
transition: transform 180ms ease, background 180ms ease, border-color 180ms ease;
}
.plugin-card:hover {
transform: translateY(-2px);
border-color: rgba(21, 20, 15, 0.24);
background: rgba(247, 241, 222, 0.82);
}
.plugin-card[hidden] { display: none; }
.plugin-card__preview {
position: relative;
display: block;
aspect-ratio: 16 / 10;
overflow: hidden;
border: 1px solid rgba(21, 20, 15, 0.13);
border-radius: 7px;
background: #17213a;
color: inherit;
text-decoration: none;
}
.plugin-card__preview img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.plugin-card__mock {
position: absolute;
inset: 0;
display: block;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px) 0 0 / 42px 42px,
linear-gradient(180deg, rgba(255, 255, 255, 0.07) 1px, transparent 1px) 0 0 / 42px 42px,
#12224a;
}
.plugin-card__mock span {
position: absolute;
display: block;
background: rgba(247, 241, 222, 0.9);
}
.plugin-card__mock span:nth-child(1) {
left: 9%;
top: 18%;
width: 58%;
height: 24px;
}
.plugin-card__mock span:nth-child(2) {
left: 9%;
top: 42%;
width: 76%;
height: 8px;
opacity: 0.7;
}
.plugin-card__mock span:nth-child(3) {
right: 9%;
bottom: 16%;
width: 24%;
height: 42px;
background: var(--coral);
}
.plugin-card__preview--image { background: #d0b169; }
.plugin-card__preview--image .plugin-card__mock {
background:
linear-gradient(135deg, rgba(21, 20, 15, 0.2), transparent 55%),
#d9b86f;
}
.plugin-card__preview--video { background: #15140f; }
.plugin-card__preview--video .plugin-card__mock {
background:
linear-gradient(90deg, rgba(237, 111, 92, 0.42) 0 18%, transparent 18% 82%, rgba(237, 111, 92, 0.42) 82%),
#15140f;
}
.plugin-card__preview--dashboard .plugin-card__mock {
background:
linear-gradient(90deg, rgba(21, 20, 15, 0.08) 1px, transparent 1px) 0 0 / 32px 32px,
linear-gradient(180deg, rgba(21, 20, 15, 0.08) 1px, transparent 1px) 0 0 / 32px 32px,
#eff2e6;
}
.plugin-card__preview--system .plugin-card__mock {
background:
linear-gradient(90deg, rgba(247, 241, 222, 0.08) 1px, transparent 1px) 0 0 / 28px 28px,
#273324;
}
.plugin-card__preview--atom .plugin-card__mock {
background:
linear-gradient(90deg, rgba(21, 20, 15, 0.08) 1px, transparent 1px) 0 0 / 38px 100%,
#f1e5c5;
}
.plugin-card__preview--workflow .plugin-card__mock {
background:
linear-gradient(135deg, rgba(237, 111, 92, 0.2), transparent 46%),
#eee2c3;
}
.plugin-card__preview-label {
position: absolute;
left: 10px;
right: 10px;
bottom: 10px;
display: block;
max-width: calc(100% - 20px);
padding: 7px 9px;
border: 1px solid rgba(21, 20, 15, 0.12);
border-radius: 999px;
background: rgba(247, 241, 222, 0.88);
color: var(--ink-soft);
font: 700 10px var(--body);
text-transform: uppercase;
letter-spacing: 0.08em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugin-card__meta,
.plugin-card__footer,
.plugin-detail__badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.plugin-card__meta {
justify-content: space-between;
color: var(--ink-faint);
font: 500 12px var(--mono);
}
.plugin-badge,
.plugin-detail__badges span {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ink-soft);
background: rgba(255, 255, 255, 0.24);
font: 700 11px var(--body);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-badge--official {
color: #6f2c1f;
border-color: rgba(237, 111, 92, 0.35);
background: rgba(237, 111, 92, 0.14);
}
.plugin-badge--community {
color: #454c20;
border-color: rgba(110, 116, 72, 0.38);
background: rgba(110, 116, 72, 0.14);
}
.plugin-card h3 {
min-width: 0;
font-family: var(--serif);
font-size: 28px;
line-height: 1.04;
letter-spacing: 0;
overflow-wrap: anywhere;
}
.plugin-card h3 a,
.plugin-card__footer a,
.plugin-back-link,
.plugin-source-list a {
color: inherit;
text-decoration-color: rgba(21, 20, 15, 0.28);
text-underline-offset: 4px;
}
.plugin-card code,
.plugin-detail__commands code,
.plugin-detail__facts dd {
font-family: var(--mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.plugin-card p {
color: var(--ink-mute);
line-height: 1.6;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.plugin-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
}
.plugin-tags span {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--line-soft);
border-radius: 999px;
color: var(--ink-mute);
background: rgba(255, 255, 255, 0.2);
font-size: 12px;
}
.plugin-tags--large {
margin-top: 22px;
}
.plugin-tags--large span {
min-height: 34px;
font-size: 13px;
}
.plugin-card__footer {
justify-content: space-between;
margin-top: 4px;
padding-top: 14px;
border-top: 1px solid var(--line-soft);
color: var(--ink-mute);
font-size: 13px;
}
.plugin-back-link {
display: inline-flex;
margin-bottom: 24px;
color: var(--ink-mute);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 12px;
}
.plugin-detail__commands {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: stretch;
max-width: 720px;
margin-top: 34px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(21, 20, 15, 0.05);
}
.plugin-detail__commands.compact {
display: block;
max-width: none;
margin-top: 28px;
}
.plugin-detail__commands div {
display: grid;
gap: 4px;
min-width: 0;
}
.plugin-detail__commands span {
color: var(--ink-mute);
font: 700 11px var(--body);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-detail__commands button {
min-width: 86px;
border: 1px solid rgba(237, 111, 92, 0.36);
border-radius: 8px;
background: var(--coral);
color: #fff;
font: 700 13px var(--body);
cursor: pointer;
}
.plugin-detail-side {
display: grid;
gap: 16px;
min-width: 0;
}
.plugin-detail-preview {
overflow: hidden;
}
.plugin-detail-preview__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 13px 14px;
border-bottom: 1px solid var(--line-soft);
color: var(--ink-mute);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-detail-preview__head small {
color: var(--ink-faint);
font: inherit;
}
.plugin-detail-preview__frame {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
background: #121f42;
}
.plugin-detail-preview__frame iframe,
.plugin-detail-preview__frame img {
display: block;
border: 0;
}
.plugin-detail-preview__frame iframe {
width: 1080px;
height: 675px;
transform: scale(0.48);
transform-origin: top left;
}
.plugin-detail-preview__frame img {
width: 100%;
height: 100%;
}
.plugin-detail-preview__frame img {
object-fit: cover;
}
.plugin-detail-preview__mock {
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgba(247, 241, 222, 0.12) 1px, transparent 1px) 0 0 / 44px 44px,
linear-gradient(180deg, rgba(247, 241, 222, 0.1) 1px, transparent 1px) 0 0 / 44px 44px,
#121f42;
}
.plugin-detail-preview__mock span {
position: absolute;
display: block;
background: rgba(247, 241, 222, 0.9);
}
.plugin-detail-preview__mock span:nth-child(1) {
left: 9%;
top: 20%;
width: 56%;
height: 42px;
}
.plugin-detail-preview__mock span:nth-child(2) {
left: 9%;
top: 42%;
width: 72%;
height: 10px;
opacity: 0.74;
}
.plugin-detail-preview__mock span:nth-child(3) {
right: 9%;
bottom: 14%;
width: 22%;
height: 64px;
background: var(--coral);
}
.plugin-detail-preview--image .plugin-detail-preview__frame { background: #d9b86f; }
.plugin-detail-preview--video .plugin-detail-preview__frame { background: #15140f; }
.plugin-detail-preview--dashboard .plugin-detail-preview__frame { background: #eff2e6; }
.plugin-detail-preview--system .plugin-detail-preview__frame { background: #263223; }
.plugin-detail-preview--atom .plugin-detail-preview__frame { background: #f1e5c5; }
.plugin-detail__facts {
padding: 22px;
}
.plugin-detail__facts dl {
display: grid;
gap: 18px;
}
.plugin-detail__facts div {
display: grid;
gap: 4px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line-soft);
}
.plugin-detail__facts div:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.plugin-detail__facts dt {
color: var(--ink-mute);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.plugin-detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.plugin-detail-panel--wide {
grid-column: 1 / -1;
}
.plugin-detail-panel {
padding: 26px;
}
.plugin-example-query {
max-height: 260px;
margin-top: 24px;
padding: 18px;
overflow: auto;
border: 1px solid var(--line-soft);
border-radius: 8px;
background: rgba(21, 20, 15, 0.05);
color: var(--ink-soft);
font: 12px/1.7 var(--mono);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.plugin-source-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.plugin-source-list a {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
font-weight: 700;
text-decoration: none;
}
.plugin-related {
padding-top: 72px;
}
@media (max-width: 980px) {
.plugin-hero__grid,
.plugin-detail-hero__grid,
.plugin-toolbar,
.plugin-detail-grid {
grid-template-columns: 1fr;
}
.plugin-hero__panel {
max-width: 640px;
}
}
@media (max-width: 640px) {
.plugin-hero,
.plugin-detail-hero {
padding: 72px 0 52px;
}
.plugin-hero h1,
.plugin-detail h1 {
font-size: clamp(40px, 12vw, 58px);
}
.plugin-hero p,
.plugin-detail-hero p,
.plugin-detail-panel p {
font-size: 16px;
}
.plugin-hero__panel {
max-width: none;
}
.plugin-hero__stats {
grid-template-columns: 1fr;
}
.plugin-card-grid {
grid-template-columns: 1fr;
}
.plugin-card {
min-height: auto;
}
.plugin-card__preview {
aspect-ratio: 16 / 9;
}
.plugin-detail-preview__frame {
aspect-ratio: 16 / 9;
}
.plugin-detail-preview__frame iframe {
width: 1120px;
height: 630px;
transform: scale(0.32);
}
.plugin-detail__commands {
grid-template-columns: 1fr;
}
.plugin-detail__commands button {
min-height: 44px;
}
}

View file

@ -0,0 +1,328 @@
---
import '../../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Header } from '../../_components/header';
import type { PublicPluginEntry } from '../../plugin-registry';
import { getPublicPlugins } from '../../plugin-registry';
export function getStaticPaths() {
return getPublicPlugins().map((plugin) => ({
params: { slug: plugin.slug },
props: { plugin },
}));
}
const { plugin } = Astro.props as { plugin: PublicPluginEntry };
const site = Astro.site ?? new URL('https://open-design.ai');
const canonical = new URL(plugin.detailHref, site).toString();
const title = `${plugin.title} — Open Design Plugin`;
const description = `${plugin.description} Install with ${plugin.installCommand}.`;
const headerHtml = renderToStaticMarkup(createElement(Header));
const related = getPublicPlugins()
.filter((item) => item.id !== plugin.id && item.registryId === plugin.registryId)
.slice(0, 6);
const previewLabel = plugin.preview?.frameHref
? 'Interactive preview'
: plugin.preview?.poster
? plugin.preview.label
: `${plugin.visualKind} preview`;
const detailLinks = [
{
href: plugin.registryUrl,
label: 'Marketplace JSON',
},
plugin.sourceUrl
? {
href: plugin.sourceUrl,
label: 'Source repository',
}
: undefined,
plugin.homepage && plugin.homepage !== plugin.sourceUrl
? {
href: plugin.homepage,
label: 'Homepage',
}
: undefined,
].filter((item): item is { href: string; label: string } => Boolean(item));
const factRows = [
['Plugin ID', plugin.id],
['Version', plugin.version],
['Registry', plugin.registryName],
['Mode', plugin.mode ?? plugin.surface ?? plugin.visualKind],
['License', plugin.license ?? 'Not specified'],
plugin.publisher ? ['Publisher', plugin.publisher] : undefined,
].filter((item): item is [string, string] => Boolean(item));
const pluginJsonLd = {
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: plugin.title,
alternateName: plugin.id,
description: plugin.description,
applicationCategory: 'DesignApplication',
operatingSystem: 'macOS, Windows, Linux',
softwareVersion: plugin.version,
url: canonical,
installUrl: plugin.sourceUrl,
codeRepository: plugin.sourceUrl,
license: plugin.license,
author: plugin.publisher
? {
'@type': 'Organization',
name: plugin.publisher,
}
: {
'@type': 'Organization',
name: 'Open Design',
},
isPartOf: {
'@type': 'WebSite',
name: 'Open Design',
url: new URL('/', site).toString(),
},
};
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Open Design',
item: new URL('/', site).toString(),
},
{
'@type': 'ListItem',
position: 2,
name: 'Plugins',
item: new URL('/plugins/', site).toString(),
},
{
'@type': 'ListItem',
position: 3,
name: plugin.title,
item: canonical,
},
],
};
---
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>{title}</title>
<meta name='description' content={description} />
<meta name='robots' content={plugin.yanked ? 'noindex,follow' : 'index,follow'} />
<link rel='canonical' href={canonical} />
<meta property='og:type' content='article' />
<meta property='og:site_name' content='Open Design' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:url' content={canonical} />
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:title' content={title} />
<meta name='twitter:description' content={description} />
<script is:inline type='application/ld+json' set:html={JSON.stringify(pluginJsonLd)}></script>
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)}></script>
</head>
<body>
<div class='side-rail right' aria-hidden='true'>
<span class='rail-text'>Open Design Plugin · {plugin.id}</span>
</div>
<div class='side-rail left' aria-hidden='true'>
<span class='rail-text'>{plugin.registryName} · {plugin.trust}</span>
</div>
<div class='shell plugin-shell'>
<div class='topbar'>
<div class='container topbar-inner'>
<span><b>OD / PLUGIN</b>&nbsp;·&nbsp;{plugin.registryName}</span>
<span class='mid'>
<span>{plugin.id}</span>
<span>{plugin.version}</span>
</span>
<span class='right'>
<a class='topbar-link' href='/plugins/'>All plugins</a>
</span>
</div>
</div>
<Fragment set:html={headerHtml} />
<main id='top' class='plugin-detail'>
<section class='plugin-detail-hero'>
<div class='container plugin-detail-hero__grid'>
<div>
<a class='plugin-back-link' href='/plugins/'>Registry</a>
<div class='plugin-detail__badges'>
<span class={`plugin-badge plugin-badge--${plugin.registryId}`}>
{plugin.registryName}
</span>
<span>{plugin.trust}</span>
{plugin.deprecated && <span>Deprecated</span>}
{plugin.yanked && <span>Yanked</span>}
</div>
<h1>{plugin.title}</h1>
<p>{plugin.description}</p>
<div class='plugin-detail__commands'>
<div>
<span>Install from registry</span>
<code>{plugin.installCommand}</code>
</div>
<button type='button' data-copy-command={plugin.installCommand}>
Copy
</button>
</div>
</div>
<aside class='plugin-detail-side' aria-label='Plugin preview and facts'>
<div class={`plugin-detail-preview plugin-detail-preview--${plugin.visualKind}`}>
<div class='plugin-detail-preview__head'>
<span>{previewLabel}</span>
<small>{plugin.preview?.type ?? plugin.visualKind}</small>
</div>
<div class='plugin-detail-preview__frame'>
{plugin.preview?.frameHref ? (
<iframe
src={plugin.preview.frameHref}
title={`${plugin.title} preview`}
loading='lazy'
sandbox='allow-scripts allow-same-origin'
></iframe>
) : plugin.preview?.poster ? (
<img src={plugin.preview.poster} alt='' loading='lazy' />
) : (
<div class='plugin-detail-preview__mock' aria-hidden='true'>
<span></span>
<span></span>
<span></span>
</div>
)}
</div>
</div>
<div class='plugin-detail__facts'>
<dl>
{factRows.map(([label, value]) => (
<div>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</div>
</aside>
</div>
</section>
<section class='plugin-detail-section'>
<div class='container plugin-detail-grid'>
<div class='plugin-detail-panel'>
<span class='label'>How it resolves</span>
<h2>Registry provenance</h2>
<p>
This entry is discovered from a marketplace catalog and resolves
to the transport source below. The product can group it by source
while the CLI keeps the install target stable through the
vendor/plugin-name ID.
</p>
<div class='plugin-source-list'>
{detailLinks.map((link) => (
<a href={link.href} target='_blank' rel='noreferrer noopener'>
{link.label}
</a>
))}
</div>
</div>
<div class='plugin-detail-panel'>
<span class='label'>Capabilities</span>
<h2>Workflow surface</h2>
<div class='plugin-tags plugin-tags--large'>
{plugin.tags.map((tag) => <span>{tag}</span>)}
{plugin.capabilities.map((capability) => <span>{capability}</span>)}
{plugin.mode && <span>{plugin.mode}</span>}
{plugin.taskKind && <span>{plugin.taskKind}</span>}
</div>
<div class='plugin-detail__commands compact'>
<div>
<span>Direct source fallback</span>
<code>{plugin.directInstallCommand}</code>
</div>
</div>
</div>
{plugin.exampleQuery && (
<div class='plugin-detail-panel plugin-detail-panel--wide'>
<span class='label'>Example prompt</span>
<h2>How people use it</h2>
<p>
The registry entry includes a ready-to-run prompt seed so the
plugin can be evaluated without guessing its expected workflow.
</p>
<pre class='plugin-example-query'>{plugin.exampleQuery}</pre>
</div>
)}
</div>
</section>
{related.length > 0 && (
<section class='plugin-detail-section plugin-related'>
<div class='container'>
<div class='section-header'>
<span class='label'>More from {plugin.registryName}</span>
<h2>Related plugins</h2>
</div>
<div class='plugin-card-grid compact'>
{related.map((item) => (
<article class='plugin-card'>
<div class='plugin-card__meta'>
<span class={`plugin-badge plugin-badge--${item.registryId}`}>
{item.registryName}
</span>
<span>{item.version}</span>
</div>
<h3>
<a href={item.detailHref}>{item.title}</a>
</h3>
<code>{item.id}</code>
<p>{item.description}</p>
</article>
))}
</div>
</div>
</section>
)}
</main>
</div>
<script is:inline>
document.querySelectorAll('[data-copy-command]').forEach((button) => {
button.addEventListener('click', async () => {
const command = button.getAttribute('data-copy-command') ?? '';
try {
await navigator.clipboard.writeText(command);
button.textContent = 'Copied';
window.setTimeout(() => {
button.textContent = 'Copy';
}, 1400);
} catch {
button.textContent = 'Select';
}
});
});
fetch('https://api.github.com/repos/nexu-io/open-design')
.then((response) => response.ok ? response.json() : undefined)
.then((repo) => {
const count = Number(repo?.stargazers_count);
if (!Number.isFinite(count)) return;
const formatted = count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count);
document.querySelectorAll('[data-github-stars]').forEach((node) => {
node.textContent = formatted;
});
})
.catch(() => {});
</script>
</body>
</html>

View file

@ -0,0 +1,321 @@
---
import '../../globals.css';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { Header } from '../../_components/header';
import { getPublicPlugins, getRegistryCounts } from '../../plugin-registry';
const plugins = getPublicPlugins();
const counts = getRegistryCounts(plugins);
const previewCount = plugins.filter((plugin) => plugin.preview).length;
const surfaceCount = new Set(
plugins.map((plugin) => plugin.mode ?? plugin.surface ?? plugin.visualKind),
).size;
const featuredPlugins = plugins
.filter((plugin) =>
Boolean(plugin.preview) ||
plugin.visualKind === 'deck' ||
plugin.visualKind === 'image' ||
plugin.visualKind === 'video',
)
.slice(0, 3);
const showcasePlugins =
featuredPlugins.length >= 3 ? featuredPlugins : plugins.slice(0, 3);
const site = Astro.site ?? new URL('https://open-design.ai');
const canonical = new URL('/plugins/', site).toString();
const title = 'Open Design Plugins — Official and community registries';
const description = `Browse ${counts.all} Open Design plugins from official and community registries. Search installable agent-native design workflows with stable vendor/plugin IDs.`;
const headerHtml = renderToStaticMarkup(createElement(Header));
const itemListJsonLd = {
'@context': 'https://schema.org',
'@type': 'ItemList',
name: 'Open Design Plugin Registry',
description,
url: canonical,
numberOfItems: counts.all,
itemListElement: plugins.slice(0, 120).map((plugin, index) => ({
'@type': 'ListItem',
position: index + 1,
url: new URL(plugin.detailHref, site).toString(),
name: plugin.title,
description: plugin.description,
})),
};
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{
'@type': 'ListItem',
position: 1,
name: 'Open Design',
item: new URL('/', site).toString(),
},
{
'@type': 'ListItem',
position: 2,
name: 'Plugins',
item: canonical,
},
],
};
const displayKind = (plugin: (typeof plugins)[number]) =>
plugin.mode ?? plugin.surface ?? plugin.visualKind;
const primaryMeta = (plugin: (typeof plugins)[number]) =>
[displayKind(plugin), plugin.preview?.label]
.filter(Boolean)
.join(' · ');
---
<!doctype html>
<html lang='en'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
<title>{title}</title>
<meta name='description' content={description} />
<meta name='robots' content='index,follow' />
<link rel='canonical' href={canonical} />
<meta property='og:type' content='website' />
<meta property='og:site_name' content='Open Design' />
<meta property='og:title' content={title} />
<meta property='og:description' content={description} />
<meta property='og:url' content={canonical} />
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:title' content={title} />
<meta name='twitter:description' content={description} />
<script is:inline type='application/ld+json' set:html={JSON.stringify(itemListJsonLd)}></script>
<script is:inline type='application/ld+json' set:html={JSON.stringify(breadcrumbJsonLd)}></script>
</head>
<body>
<div class='side-rail right' aria-hidden='true'>
<span class='rail-text'>Open Design Registry · Official · Community</span>
</div>
<div class='side-rail left' aria-hidden='true'>
<span class='rail-text'>vendor/plugin-name · marketplace.json</span>
</div>
<div class='shell plugin-shell'>
<div class='topbar'>
<div class='container topbar-inner'>
<span><b>OD / REGISTRY</b>&nbsp;·&nbsp;Public index</span>
<span class='mid'>
<span>Official · Community · Self-hosted</span>
<span>GitHub-backed today · database-ready later</span>
</span>
<span class='right'>
<a class='topbar-link' href='https://github.com/nexu-io/open-design/tree/main/plugins/registry' target='_blank' rel='noreferrer noopener'>
Source JSON
</a>
</span>
</div>
</div>
<Fragment set:html={headerHtml} />
<main id='top' class='plugin-directory'>
<section class='plugin-hero'>
<div class='container plugin-hero__grid'>
<div>
<span class='label'>Plugin Registry · public ecosystem</span>
<h1>Browse agent-native design plugins with live previews.</h1>
<p>
Discover installable workflows, decks, image templates, design
systems, and atomic capabilities. Each entry keeps a stable
vendor/plugin ID, clear provenance, and a visual cue so browsing
the registry feels closer to choosing a creative tool than reading
a manifest dump.
</p>
<div class='plugin-hero__actions'>
<a class='btn btn-primary' href='#registry-results'>
Browse registry
</a>
<a class='btn btn-ghost' href='https://raw.githubusercontent.com/nexu-io/open-design/main/plugins/registry/community/open-design-marketplace.json' target='_blank' rel='noreferrer noopener'>
Community marketplace.json
</a>
</div>
</div>
<aside class='plugin-hero__panel' aria-label='Registry preview'>
<div class='plugin-showcase__head'>
<span>Registry preview</span>
<strong>{counts.all}</strong>
<small>installable entries</small>
</div>
<div class='plugin-showcase__list'>
{showcasePlugins.map((plugin, index) => (
<a
class={`plugin-showcase-item plugin-showcase-item--${plugin.visualKind}`}
href={plugin.detailHref}
>
<span class='plugin-showcase-item__visual' aria-hidden='true'>
{plugin.preview?.poster ? (
<img src={plugin.preview.poster} alt='' loading='lazy' />
) : (
<>
<span />
<span />
<span />
</>
)}
</span>
<span class='plugin-showcase-item__copy'>
<small>0{index + 1} · {displayKind(plugin)}</small>
<strong>{plugin.title}</strong>
<em>{plugin.id}</em>
</span>
</a>
))}
</div>
<dl class='plugin-hero__stats'>
<div>
<dt>{counts.official}</dt>
<dd>official</dd>
</div>
<div>
<dt>{previewCount}</dt>
<dd>with preview</dd>
</div>
<div>
<dt>{surfaceCount}</dt>
<dd>surfaces</dd>
</div>
</dl>
</aside>
</div>
</section>
<section class='plugin-registry-section' id='registry-results'>
<div class='container'>
<div class='plugin-toolbar' data-plugin-toolbar>
<div>
<span class='label'>Available from sources</span>
<h2>Registry entries</h2>
</div>
<label class='plugin-search'>
<span class='sr-only'>Search plugins</span>
<input data-plugin-search type='search' placeholder='Search plugins, workflows, vendors...' autocomplete='off' />
</label>
</div>
<div class='plugin-filter-row' aria-label='Registry filters'>
<button class='plugin-filter is-active' type='button' data-plugin-filter='all' aria-pressed='true'>
All <span>{counts.all}</span>
</button>
<button class='plugin-filter' type='button' data-plugin-filter='official' aria-pressed='false'>
Official <span>{counts.official}</span>
</button>
<button class='plugin-filter' type='button' data-plugin-filter='community' aria-pressed='false'>
Community <span>{counts.community}</span>
</button>
</div>
<p class='plugin-result-count'>
<span data-plugin-visible-count>{counts.all}</span> visible plugins
</p>
<div class='plugin-card-grid'>
{plugins.map((plugin) => (
<article
class='plugin-card'
data-plugin-card
data-registry={plugin.registryId}
data-search={plugin.searchText}
>
<a
class={`plugin-card__preview plugin-card__preview--${plugin.visualKind}`}
href={plugin.detailHref}
aria-label={`Open ${plugin.title} details`}
>
{plugin.preview?.poster ? (
<img src={plugin.preview.poster} alt='' loading='lazy' />
) : (
<span class='plugin-card__mock' aria-hidden='true'>
<span />
<span />
<span />
</span>
)}
<span class='plugin-card__preview-label'>
{primaryMeta(plugin)}
</span>
</a>
<div class='plugin-card__meta'>
<span class={`plugin-badge plugin-badge--${plugin.registryId}`}>
{plugin.registryName}
</span>
<span>{plugin.version}</span>
</div>
<h3>
<a href={plugin.detailHref}>{plugin.title}</a>
</h3>
<code>{plugin.id}</code>
<p>{plugin.description}</p>
<div class='plugin-tags'>
{plugin.tags.slice(0, 5).map((tag) => <span>{tag}</span>)}
{plugin.capabilities.slice(0, 3).map((capability) => (
<span>{capability}</span>
))}
</div>
<div class='plugin-card__footer'>
<a href={plugin.detailHref}>Details</a>
<span>{plugin.trust}</span>
</div>
</article>
))}
</div>
</div>
</section>
</main>
</div>
<script is:inline>
const searchInput = document.querySelector('[data-plugin-search]');
const cards = Array.from(document.querySelectorAll('[data-plugin-card]'));
const filterButtons = Array.from(document.querySelectorAll('[data-plugin-filter]'));
const visibleCount = document.querySelector('[data-plugin-visible-count]');
let activeFilter = 'all';
const applyFilters = () => {
const query = String(searchInput?.value ?? '').trim().toLowerCase();
let total = 0;
for (const card of cards) {
const matchesFilter = activeFilter === 'all' || card.dataset.registry === activeFilter;
const matchesSearch = !query || String(card.dataset.search ?? '').includes(query);
const visible = matchesFilter && matchesSearch;
card.hidden = !visible;
if (visible) total += 1;
}
if (visibleCount) {
visibleCount.textContent = String(total);
}
};
searchInput?.addEventListener('input', applyFilters);
for (const button of filterButtons) {
button.addEventListener('click', () => {
activeFilter = button.dataset.pluginFilter ?? 'all';
for (const item of filterButtons) {
const selected = item === button;
item.classList.toggle('is-active', selected);
item.setAttribute('aria-pressed', String(selected));
}
applyFilters();
});
}
fetch('https://api.github.com/repos/nexu-io/open-design')
.then((response) => response.ok ? response.json() : undefined)
.then((repo) => {
const count = Number(repo?.stargazers_count);
if (!Number.isFinite(count)) return;
const formatted = count >= 1000 ? `${(count / 1000).toFixed(1)}K` : String(count);
document.querySelectorAll('[data-github-stars]').forEach((node) => {
node.textContent = formatted;
});
})
.catch(() => {});
</script>
</body>
</html>

View file

@ -0,0 +1,19 @@
---
import { getPluginPreviewHtml, getPublicPlugins } from '../../../plugin-registry';
export function getStaticPaths() {
return getPublicPlugins()
.filter((plugin) => plugin.preview?.type === 'html' && plugin.preview.frameHref)
.map((plugin) => ({
params: { slug: plugin.slug },
props: {
html: getPluginPreviewHtml(plugin),
},
}))
.filter((entry) => Boolean(entry.props.html));
}
const { html } = Astro.props as { html: string };
---
<Fragment set:html={html} />

View file

@ -0,0 +1,35 @@
import type { APIRoute } from 'astro';
import { getPublicPlugins } from '../../plugin-registry';
export const GET: APIRoute = () => {
const plugins = getPublicPlugins().map((plugin) => ({
id: plugin.id,
title: plugin.title,
description: plugin.description,
registryId: plugin.registryId,
trust: plugin.trust,
version: plugin.version,
mode: plugin.mode,
surface: plugin.surface,
visualKind: plugin.visualKind,
preview: plugin.preview
? {
type: plugin.preview.type,
label: plugin.preview.label,
poster: plugin.preview.poster,
frameHref: plugin.preview.frameHref,
}
: undefined,
tags: plugin.tags,
capabilities: plugin.capabilities,
href: plugin.detailHref,
installCommand: plugin.installCommand,
}));
return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), plugins }, null, 2), {
headers: {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'public, max-age=300',
},
});
};

View file

@ -0,0 +1,627 @@
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
type TrustTier = 'official' | 'trusted' | 'restricted';
type RawMarketplace = {
id?: unknown;
name?: unknown;
description?: unknown;
trust?: unknown;
plugins?: unknown;
};
type RawPluginEntry = {
name?: unknown;
title?: unknown;
description?: unknown;
version?: unknown;
tags?: unknown;
source?: unknown;
dist?: unknown;
integrity?: unknown;
publisher?: unknown;
homepage?: unknown;
license?: unknown;
capabilitiesSummary?: unknown;
mode?: unknown;
taskKind?: unknown;
preview?: unknown;
yanked?: unknown;
deprecated?: unknown;
};
type RawPluginManifest = {
name?: unknown;
title?: unknown;
description?: unknown;
version?: unknown;
tags?: unknown;
homepage?: unknown;
license?: unknown;
od?: unknown;
};
type RawOdMetadata = {
kind?: unknown;
mode?: unknown;
surface?: unknown;
taskKind?: unknown;
capabilities?: unknown;
preview?: unknown;
useCase?: unknown;
};
export type PublicPluginPreview = {
type: 'html' | 'image' | 'video';
label: string;
poster: string | undefined;
frameHref: string | undefined;
localHtmlPath: string | undefined;
};
export type PublicPluginEntry = {
id: string;
slug: string;
title: string;
description: string;
version: string;
registryId: string;
registryName: string;
trust: TrustTier;
source: string;
sourceUrl: string | undefined;
registryUrl: string;
detailHref: string;
installCommand: string;
directInstallCommand: string;
tags: string[];
capabilities: string[];
publisher: string | undefined;
homepage: string | undefined;
license: string | undefined;
integrity: string | undefined;
mode: string | undefined;
taskKind: string | undefined;
surface: string | undefined;
visualKind: string;
preview: PublicPluginPreview | undefined;
exampleQuery: string | undefined;
yanked: boolean;
deprecated: boolean;
searchText: string;
};
const REPO = 'https://github.com/nexu-io/open-design';
const RAW_REPO = 'https://raw.githubusercontent.com/nexu-io/open-design/main';
const findRepoRoot = () => {
const candidates = [
process.cwd(),
path.resolve(process.cwd(), '..'),
path.resolve(process.cwd(), '../..'),
fileURLToPath(new URL('../../..', import.meta.url)),
];
for (const candidate of candidates) {
if (existsSync(path.join(candidate, 'pnpm-workspace.yaml'))) {
return candidate;
}
}
return fileURLToPath(new URL('../../..', import.meta.url));
};
const REPO_ROOT = findRepoRoot();
const REGISTRY_ROOT = path.join(REPO_ROOT, 'plugins', 'registry');
const OFFICIAL_PLUGINS_ROOT = path.join(REPO_ROOT, 'plugins', '_official');
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const asString = (value: unknown): string | undefined =>
typeof value === 'string' && value.trim() ? value.trim() : undefined;
const asBoolean = (value: unknown): boolean =>
typeof value === 'boolean' ? value : false;
const asStringArray = (value: unknown): string[] =>
Array.isArray(value)
? value
.map((item) => asString(item))
.filter((item): item is string => Boolean(item))
: [];
const toPosix = (value: string) => value.split(path.sep).join('/');
const isFile = (filePath: string) => {
try {
return statSync(filePath).isFile();
} catch {
return false;
}
};
const normalizeTrust = (value: unknown, fallback: TrustTier): TrustTier => {
const trust = asString(value);
if (trust === 'official' || trust === 'trusted' || trust === 'restricted') {
return trust;
}
return fallback;
};
const registryTrustFallback = (registryId: string): TrustTier =>
registryId === 'official' ? 'official' : 'restricted';
const titleize = (value: string) =>
value
.split(/[-_/]+/g)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const slugSegment = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '') || 'plugin';
const detailHrefFor = (id: string) =>
`/plugins/${id.split('/').map(slugSegment).join('/')}/`;
const previewHrefFor = (id: string) =>
`/plugins/previews/${id.split('/').map(slugSegment).join('/')}/`;
const sourceUrlFromSource = (source: string): string | undefined => {
const match = /^github:([^/]+)\/([^@]+)@([^/]+)\/(.+)$/.exec(source);
if (!match) {
return source.startsWith('http://') || source.startsWith('https://')
? source
: undefined;
}
const [, owner, repo, ref, repoPath] = match;
return `https://github.com/${owner}/${repo}/tree/${ref}/${repoPath}`;
};
const registryUrlFor = (registryId: string) =>
`${RAW_REPO}/plugins/registry/${registryId}/open-design-marketplace.json`;
const previewLabelFor = (type: string | undefined) => {
if (type === 'image') return 'Image preview';
if (type === 'video') return 'Video poster';
return 'Live HTML preview';
};
const localizedString = (value: unknown): string | undefined => {
const text = asString(value);
if (text) {
return text;
}
const record = asRecord(value);
return (
asString(record?.en) ??
asString(record?.['zh-CN']) ??
asString(record?.['zh']) ??
Object.values(record ?? {})
.map((item) => asString(item))
.find(Boolean)
);
};
const useCaseQuery = (value: unknown): string | undefined => {
const record = asRecord(value);
return localizedString(record?.query);
};
const localPluginPath = (pluginDir: string, entry: string): string | undefined => {
const resolved = path.resolve(pluginDir, entry);
const root = path.resolve(pluginDir);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) {
return undefined;
}
return isFile(resolved) ? resolved : undefined;
};
const canRenderHtmlPreview = (filePath: string) => {
try {
const html = readFileSync(filePath, 'utf8');
return !/(?:src|href)=["'](?!https?:|\/\/|data:|#|mailto:|tel:)[^"']+/i.test(html);
} catch {
return false;
}
};
const previewFrom = (
pluginDir: string | undefined,
id: string,
rawPreview: unknown,
): PublicPluginPreview | undefined => {
const preview = asRecord(rawPreview);
const rawType = asString(preview?.type);
const poster = asString(preview?.poster);
const entry = asString(preview?.entry);
const url = asString(preview?.url);
if (rawType === 'image' || (!rawType && poster)) {
return {
type: 'image',
label: previewLabelFor('image'),
poster,
frameHref: undefined,
localHtmlPath: undefined,
};
}
if (rawType === 'video') {
return {
type: 'video',
label: previewLabelFor('video'),
poster,
frameHref: undefined,
localHtmlPath: undefined,
};
}
if (rawType === 'html') {
const localHtmlPath =
pluginDir && entry ? localPluginPath(pluginDir, entry) : undefined;
if (localHtmlPath) {
const frameHref = canRenderHtmlPreview(localHtmlPath)
? previewHrefFor(id)
: undefined;
return {
type: 'html',
label: previewLabelFor('html'),
poster,
frameHref,
localHtmlPath: frameHref ? localHtmlPath : undefined,
};
}
}
if (url && (url.startsWith('http://') || url.startsWith('https://'))) {
return {
type: 'html',
label: previewLabelFor('html'),
poster,
frameHref: url,
localHtmlPath: undefined,
};
}
return undefined;
};
const visualKindFor = (
title: string,
tags: string[],
capabilities: string[],
mode: string | undefined,
surface: string | undefined,
preview: PublicPluginPreview | undefined,
) => {
const haystack = [
title,
mode,
surface,
preview?.type,
...tags,
...capabilities,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
if (preview?.type === 'image' || haystack.includes('image')) return 'image';
if (preview?.type === 'video' || haystack.includes('video')) return 'video';
if (
haystack.includes('deck') ||
haystack.includes('ppt') ||
haystack.includes('slide') ||
haystack.includes('presentation')
) {
return 'deck';
}
if (haystack.includes('dashboard') || haystack.includes('report')) return 'dashboard';
if (haystack.includes('design-system') || haystack.includes('system')) return 'system';
if (haystack.includes('atom')) return 'atom';
return 'workflow';
};
const readJson = <T>(filePath: string): T | undefined => {
try {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
} catch {
return undefined;
}
};
const findManifestFiles = (dir: string): string[] => {
if (!existsSync(dir)) {
return [];
}
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...findManifestFiles(entryPath));
} else if (entry.isFile() && entry.name === 'open-design.json') {
files.push(entryPath);
}
}
return files;
};
const entryFromMarketplace = (
registryId: string,
registryName: string,
registryTrust: TrustTier,
rawEntry: RawPluginEntry,
): PublicPluginEntry | undefined => {
const id = asString(rawEntry.name);
const source = asString(rawEntry.source ?? rawEntry.dist);
if (!id || !source) {
return undefined;
}
const trust = registryTrust;
const title = asString(rawEntry.title) ?? titleize(id.split('/').at(-1) ?? id);
const description =
asString(rawEntry.description) ??
'Agent-native Open Design workflow packaged as a portable plugin.';
const tags = asStringArray(rawEntry.tags);
const capabilities = asStringArray(rawEntry.capabilitiesSummary);
const version = asString(rawEntry.version) ?? '0.1.0';
const publisher = publisherLabel(rawEntry.publisher);
const detailHref = detailHrefFor(id);
const mode = asString(rawEntry.mode);
const taskKind = asString(rawEntry.taskKind);
const surface = undefined;
const preview = previewFrom(undefined, id, rawEntry.preview);
return {
id,
slug: id.split('/').map(slugSegment).join('/'),
title,
description,
version,
registryId,
registryName,
trust,
source,
sourceUrl: sourceUrlFromSource(source),
registryUrl: registryUrlFor(registryId),
detailHref,
installCommand: `od plugin install ${id}`,
directInstallCommand: `od plugin install ${source}`,
tags,
capabilities,
publisher,
homepage: asString(rawEntry.homepage),
license: asString(rawEntry.license),
integrity: asString(rawEntry.integrity),
mode,
taskKind,
surface,
visualKind: visualKindFor(title, tags, capabilities, mode, surface, preview),
preview,
exampleQuery: undefined,
yanked: asBoolean(rawEntry.yanked),
deprecated: asBoolean(rawEntry.deprecated),
searchText: [
id,
title,
description,
registryName,
trust,
publisher,
mode,
taskKind,
...tags,
...capabilities,
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
};
};
const publisherLabel = (publisher: unknown): string | undefined => {
const text = asString(publisher);
if (text) {
return text;
}
const record = asRecord(publisher);
return asString(record?.name) ?? asString(record?.id) ?? asString(record?.github);
};
const loadRegistryEntries = (): PublicPluginEntry[] => {
if (!existsSync(REGISTRY_ROOT)) {
return [];
}
const entries: PublicPluginEntry[] = [];
for (const dirent of readdirSync(REGISTRY_ROOT, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const registryId = dirent.name;
const manifestPath = path.join(REGISTRY_ROOT, registryId, 'open-design-marketplace.json');
const manifest = readJson<RawMarketplace>(manifestPath);
const rawPlugins = Array.isArray(manifest?.plugins) ? manifest.plugins : [];
const registryName = asString(manifest?.name) ?? titleize(registryId);
const registryTrust = normalizeTrust(
manifest?.trust,
registryTrustFallback(registryId),
);
for (const item of rawPlugins) {
const rawEntry = asRecord(item) as RawPluginEntry | undefined;
if (!rawEntry) {
continue;
}
const entry = entryFromMarketplace(
registryId,
registryName,
registryTrust,
rawEntry,
);
if (entry) {
entries.push(entry);
}
}
}
return entries;
};
const officialEntryFromManifest = (manifestPath: string): PublicPluginEntry | undefined => {
const manifest = readJson<RawPluginManifest>(manifestPath);
const pluginName = asString(manifest?.name) ?? path.basename(path.dirname(manifestPath));
const id = `open-design/${pluginName}`;
const pluginDir = path.dirname(manifestPath);
const repoPath = toPosix(path.relative(REPO_ROOT, pluginDir));
const source = `github:nexu-io/open-design@main/${repoPath}`;
const od = asRecord(manifest?.od) as RawOdMetadata | undefined;
const capabilities = asStringArray(od?.capabilities);
const tags = asStringArray(manifest?.tags);
const title = asString(manifest?.title) ?? titleize(pluginName);
const description =
asString(manifest?.description) ??
'First-party Open Design workflow packaged as a portable plugin.';
const detailHref = detailHrefFor(id);
const mode = asString(od?.mode);
const taskKind = asString(od?.taskKind);
const surface = asString(od?.surface);
const preview = previewFrom(pluginDir, id, od?.preview);
const exampleQuery = useCaseQuery(od?.useCase);
return {
id,
slug: id.split('/').map(slugSegment).join('/'),
title,
description,
version: asString(manifest?.version) ?? '0.1.0',
registryId: 'official',
registryName: 'Official',
trust: 'official',
source,
sourceUrl: `${REPO}/tree/main/${repoPath}`,
registryUrl: registryUrlFor('official'),
detailHref,
installCommand: `od plugin install ${id}`,
directInstallCommand: `od plugin install ${source}`,
tags,
capabilities,
publisher: undefined,
homepage: asString(manifest?.homepage),
license: asString(manifest?.license),
integrity: undefined,
mode,
taskKind,
surface,
visualKind: visualKindFor(title, tags, capabilities, mode, surface, preview),
preview,
exampleQuery,
yanked: false,
deprecated: false,
searchText: [
id,
title,
description,
'Official',
'official',
mode,
taskKind,
surface,
exampleQuery,
...tags,
...capabilities,
]
.filter(Boolean)
.join(' ')
.toLowerCase(),
};
};
const loadBundledOfficialEntries = (): PublicPluginEntry[] =>
findManifestFiles(OFFICIAL_PLUGINS_ROOT)
.map(officialEntryFromManifest)
.filter((entry): entry is PublicPluginEntry => Boolean(entry));
export const getPublicPlugins = (): PublicPluginEntry[] => {
const byId = new Map<string, PublicPluginEntry>();
for (const entry of loadRegistryEntries()) {
byId.set(entry.id, entry);
}
for (const entry of loadBundledOfficialEntries()) {
const existing = byId.get(entry.id);
if (existing) {
const preview = existing.preview ?? entry.preview;
const mode = existing.mode ?? entry.mode;
const taskKind = existing.taskKind ?? entry.taskKind;
const surface = existing.surface ?? entry.surface;
const tags = existing.tags.length > 0 ? existing.tags : entry.tags;
const capabilities =
existing.capabilities.length > 0 ? existing.capabilities : entry.capabilities;
byId.set(entry.id, {
...existing,
mode,
taskKind,
surface,
tags,
capabilities,
preview,
exampleQuery: existing.exampleQuery ?? entry.exampleQuery,
visualKind: visualKindFor(
existing.title,
tags,
capabilities,
mode,
surface,
preview,
),
});
} else {
byId.set(entry.id, entry);
}
}
return [...byId.values()].sort((left, right) => {
const sourceOrder = (entry: PublicPluginEntry) =>
entry.registryId === 'official' ? 0 : entry.registryId === 'community' ? 1 : 2;
const order = sourceOrder(left) - sourceOrder(right);
if (order !== 0) {
return order;
}
return left.title.localeCompare(right.title, 'en');
});
};
export const getRegistryCounts = (plugins = getPublicPlugins()) => ({
all: plugins.length,
official: plugins.filter((plugin) => plugin.registryId === 'official').length,
community: plugins.filter((plugin) => plugin.registryId === 'community').length,
restricted: plugins.filter((plugin) => plugin.trust === 'restricted').length,
});
export const getPluginPreviewHtml = (plugin: PublicPluginEntry): string | undefined => {
const filePath = plugin.preview?.localHtmlPath;
if (!filePath) {
return undefined;
}
try {
return readFileSync(filePath, 'utf8');
} catch {
return undefined;
}
};

View file

@ -12,6 +12,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import {
defaultScenarioPluginIdForKind,
type ConnectorDetail,
type InstalledPluginRecord,
} from '@open-design/contracts';
import { LOCALE_LABEL, LOCALES, useI18n, useT, type Locale } from '../i18n';
import { navigate, useRoute } from '../router';
@ -38,8 +39,8 @@ import { GithubStarBadge } from './GithubStarBadge';
import { formatStars, GITHUB_REPO_URL, useGithubStars } from './useGithubStars';
import { HomeView } from './HomeView';
import {
buildPluginAuthoringPrompt,
createPluginAuthoringHandoff,
createPluginUseHandoff,
type HomePromptHandoff,
} from './home-hero/plugin-authoring';
import { Icon } from './Icon';
@ -271,14 +272,16 @@ export function EntryShell({
function startPluginAuthoring(goal?: string) {
setHomePromptHandoff(
createPluginAuthoringHandoff(
Date.now(),
goal ? buildPluginAuthoringPrompt(goal) : undefined,
),
createPluginAuthoringHandoff(Date.now(), goal),
);
changeView('home');
}
function usePluginFromLibrary(record: InstalledPluginRecord) {
setHomePromptHandoff(createPluginUseHandoff(Date.now(), record.id));
changeView('home');
}
useEffect(() => {
setIntegrationTab(integrationInitialTab);
}, [integrationInitialTab]);
@ -331,6 +334,9 @@ export function EntryShell({
: fallbackName;
const metadata: ProjectMetadata = {
kind: payload.projectKind ?? 'prototype',
...(payload.contextPlugins && payload.contextPlugins.length > 0
? { contextPlugins: payload.contextPlugins }
: {}),
};
onCreateProject({
name,
@ -661,6 +667,7 @@ export function EntryShell({
onSubmit={handlePluginLoopSubmit}
onOpenProject={onOpenProject}
onViewAllProjects={() => changeView('projects')}
onBrowseRegistry={() => changeView('plugins')}
onImportFolder={handleChipFolderImport}
onOpenNewProject={(tab) => {
// Stage B of plugin-driven-flow-plan: the rail's
@ -704,6 +711,7 @@ export function EntryShell({
{view === 'plugins' ? (
<PluginsView
onCreatePlugin={startPluginAuthoring}
onUsePlugin={usePluginFromLibrary}
onCreatePluginShareProject={onCreatePluginShareProject}
/>
) : null}

View file

@ -9,9 +9,10 @@
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { InstalledPluginRecord, McpServerConfig } from '@open-design/contracts';
import type { InputFieldSpec, InstalledPluginRecord, McpServerConfig } from '@open-design/contracts';
import type { SkillSummary } from '../types';
import { Icon, type IconName } from './Icon';
import { PluginInputsForm } from './PluginInputsForm';
import {
chipsForGroup,
type ChipGroup,
@ -32,6 +33,14 @@ interface Props {
activeSkillId?: string | null;
activeSkillTitle?: string | null;
onClearActiveSkill?: () => void;
selectedPluginContexts?: InstalledPluginRecord[];
onRemovePluginContext?: (pluginId: string) => void;
onOpenPluginDetails?: (record: InstalledPluginRecord) => void;
pluginInputFields?: InputFieldSpec[];
pluginInputValues?: Record<string, unknown>;
pluginInputTemplate?: string | null;
onPluginInputValuesChange?: (values: Record<string, unknown>) => void;
onPluginInputValidityChange?: (valid: boolean) => void;
pluginOptions: InstalledPluginRecord[];
pluginsLoading: boolean;
skillOptions?: SkillSummary[];
@ -57,6 +66,7 @@ interface HomeMentionOption {
title: string;
description: string;
meta: string;
pluginRecord?: InstalledPluginRecord;
disabled?: boolean;
onPick: () => void;
}
@ -78,6 +88,14 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
activeChipId,
onClearActivePlugin,
onClearActiveSkill = () => undefined,
selectedPluginContexts = [],
onRemovePluginContext = () => undefined,
onOpenPluginDetails = () => undefined,
pluginInputFields = [],
pluginInputValues = {},
pluginInputTemplate = null,
onPluginInputValuesChange = () => undefined,
onPluginInputValidityChange = () => undefined,
pluginOptions,
pluginsLoading,
skillOptions = [],
@ -98,6 +116,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [mentionTab, setMentionTab] = useState<HomeMentionTab>('all');
const [hoveredPlugin, setHoveredPlugin] = useState<InstalledPluginRecord | null>(null);
const composingRef = useRef(false);
const canSubmit = prompt.trim().length > 0 && !submitDisabled;
const placeholder = activePluginTitle || activeSkillTitle
@ -148,6 +167,7 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
title: plugin.title,
description: plugin.manifest?.description ?? plugin.id,
meta: pendingPluginId === plugin.id ? 'Applying…' : getPluginSourceLabel(plugin),
pluginRecord: plugin,
disabled: pendingPluginId !== null,
onPick: () => pickPlugin(plugin),
})),
@ -188,13 +208,21 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
(mentionTab === 'plugins' && pluginsLoading) ||
(mentionTab === 'skills' && skillsLoading) ||
(mentionTab === 'mcp' && mcpLoading);
const promptHighlightParts = useMemo(
() => buildPromptHighlightParts(pluginInputTemplate, pluginInputValues, prompt),
[pluginInputTemplate, pluginInputValues, prompt],
);
useEffect(() => {
if (selectedIndex >= visiblePickerOptions.length) setSelectedIndex(0);
}, [selectedIndex, visiblePickerOptions.length]);
useEffect(() => {
if (!pickerOpen) setHoveredPlugin(null);
}, [pickerOpen]);
function pickPlugin(record: InstalledPluginRecord) {
const nextPrompt = mention ? replaceMentionToken(prompt, mention) : null;
const nextPrompt = mention ? replaceMentionToken(prompt, mention) ?? '' : prompt;
onPickPlugin(record, nextPrompt);
}
@ -231,8 +259,34 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
</p>
<div className="home-hero__input-card">
{activePluginTitle || activeSkillTitle ? (
{activePluginTitle || activeSkillTitle || selectedPluginContexts.length > 0 ? (
<div className="home-hero__active">
{selectedPluginContexts.map((plugin) => (
<span
key={plugin.id}
className="home-hero__active-chip home-hero__active-chip--context"
data-testid={`home-hero-context-plugin-${plugin.id}`}
>
<button
type="button"
className="home-hero__active-chip-body"
onClick={() => onOpenPluginDetails(plugin)}
title={`Plugin: ${plugin.title}`}
>
<span className="home-hero__active-dot" aria-hidden />
<span>@{plugin.title}</span>
</button>
<button
type="button"
className="home-hero__active-clear"
onClick={() => onRemovePluginContext(plugin.id)}
aria-label={`Remove plugin ${plugin.title}`}
title="Remove plugin"
>
×
</button>
</span>
))}
{activePluginTitle ? (
<span className="home-hero__active-chip" data-testid="home-hero-active-plugin">
<span className="home-hero__active-dot" aria-hidden />
@ -273,64 +327,107 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
) : null}
</div>
) : null}
<textarea
ref={ref}
className="home-hero__input"
data-testid="home-hero-input"
value={prompt}
onChange={(e) => {
onPromptChange(e.target.value);
setSelectedIndex(0);
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
}}
onKeyDown={(e) => {
if (isImeComposing(e, composingRef.current)) return;
if (pickerOpen && visiblePickerOptions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((idx) => (idx + 1) % visiblePickerOptions.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(
(idx) => (idx - 1 + visiblePickerOptions.length) % visiblePickerOptions.length,
);
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
if (selected && !selected.disabled) selected.onPick();
return;
}
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
e.preventDefault();
if (pickerOpen && visiblePickerOptions.length > 0) {
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
if (selected && !selected.disabled) selected.onPick();
return;
}
if (canSubmit) onSubmit();
}
}}
placeholder={placeholder}
rows={3}
aria-controls={pickerOpen ? 'home-hero-context-picker' : undefined}
aria-expanded={pickerOpen}
/>
<div
className={`home-hero__prompt-surface${
pluginInputFields.length > 0 ? ' home-hero__prompt-surface--with-inputs' : ''
}`}
>
<div
className={`home-hero__prompt-editor${
promptHighlightParts ? ' home-hero__prompt-editor--highlighted' : ''
}`}
>
{promptHighlightParts ? (
<div
className="home-hero__prompt-highlight"
data-testid="home-hero-prompt-highlight"
aria-hidden
>
{promptHighlightParts.map((part, index) => (
part.kind === 'slot' ? (
<span
key={`${part.key}-${index}`}
className="home-hero__prompt-slot"
data-field-name={part.key}
data-filled={part.filled}
data-testid={`home-hero-prompt-slot-${part.key}`}
>
{part.text}
</span>
) : (
<span key={`text-${index}`}>{part.text}</span>
)
))}
</div>
) : null}
<textarea
ref={ref}
className="home-hero__input"
data-testid="home-hero-input"
value={prompt}
onChange={(e) => {
onPromptChange(e.target.value);
setSelectedIndex(0);
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
}}
onKeyDown={(e) => {
if (isImeComposing(e, composingRef.current)) return;
if (pickerOpen && visiblePickerOptions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((idx) => (idx + 1) % visiblePickerOptions.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex(
(idx) => (idx - 1 + visiblePickerOptions.length) % visiblePickerOptions.length,
);
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
if (selected && !selected.disabled) selected.onPick();
return;
}
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey
) {
e.preventDefault();
if (pickerOpen && visiblePickerOptions.length > 0) {
const selected = visiblePickerOptions[selectedIndex] ?? visiblePickerOptions[0];
if (selected && !selected.disabled) selected.onPick();
return;
}
if (canSubmit) onSubmit();
}
}}
placeholder={placeholder}
rows={3}
aria-controls={pickerOpen ? 'home-hero-context-picker' : undefined}
aria-expanded={pickerOpen}
/>
</div>
{pluginInputFields.length > 0 ? (
<PluginInputsForm
fields={pluginInputFields}
values={pluginInputValues}
onChange={onPluginInputValuesChange}
onValidityChange={onPluginInputValidityChange}
/>
) : null}
</div>
{pickerOpen ? (
<div
id="home-hero-context-picker"
@ -385,7 +482,10 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
className={`home-hero__plugin-option${
optionIndex === selectedIndex ? ' is-active' : ''
}`}
onMouseEnter={() => setSelectedIndex(optionIndex)}
onMouseEnter={() => {
setSelectedIndex(optionIndex);
setHoveredPlugin(item.pluginRecord ?? null);
}}
onMouseDown={(event) => {
event.preventDefault();
if (!item.disabled) item.onPick();
@ -407,6 +507,33 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
})}
</div>
))}
{hoveredPlugin ? (
<div
className="home-hero__plugin-hover-card"
data-testid="home-hero-plugin-hover-card"
>
<div>
<span className="home-hero__plugin-hover-kicker">
{getPluginSourceLabel(hoveredPlugin)}
</span>
<strong>{hoveredPlugin.title}</strong>
<p>{hoveredPlugin.manifest?.description ?? hoveredPlugin.id}</p>
</div>
<div className="home-hero__plugin-hover-meta">
<span>{(hoveredPlugin.manifest?.od?.inputs ?? []).length} parameters</span>
{getPluginQueryPreview(hoveredPlugin) ? (
<span>{getPluginQueryPreview(hoveredPlugin)}</span>
) : null}
</div>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onOpenPluginDetails(hoveredPlugin)}
>
Details
</button>
</div>
) : null}
</div>
) : null}
<div className="home-hero__input-foot">
@ -467,6 +594,66 @@ interface ContextMention {
query: string;
}
interface PromptHighlightPart {
kind: 'text' | 'slot';
text: string;
key?: string;
filled?: boolean;
}
const INPUT_PLACEHOLDER_PATTERN = /\{\{\s*([a-zA-Z_][\w-]*)\s*\}\}/g;
function buildPromptHighlightParts(
template: string | null,
values: Record<string, unknown>,
prompt: string,
): PromptHighlightPart[] | null {
if (!template) return null;
INPUT_PLACEHOLDER_PATTERN.lastIndex = 0;
const parts: PromptHighlightPart[] = [];
let rendered = '';
let lastIndex = 0;
let slotCount = 0;
let match: RegExpExecArray | null;
while ((match = INPUT_PLACEHOLDER_PATTERN.exec(template)) !== null) {
const placeholder = match[0];
const key = match[1];
if (!key) continue;
const literal = template.slice(lastIndex, match.index);
if (literal) {
parts.push({ kind: 'text', text: literal });
rendered += literal;
}
const replacement = stringifyTemplateValue(values[key], placeholder);
parts.push({
kind: 'slot',
key,
text: replacement.text,
filled: replacement.filled,
});
rendered += replacement.text;
slotCount += 1;
lastIndex = match.index + placeholder.length;
}
const tail = template.slice(lastIndex);
if (tail) {
parts.push({ kind: 'text', text: tail });
rendered += tail;
}
if (slotCount === 0 || rendered !== prompt) return null;
return parts;
}
function stringifyTemplateValue(
value: unknown,
placeholder: string,
): { text: string; filled: boolean } {
if (value === undefined || value === null || value === '') {
return { text: placeholder, filled: false };
}
return { text: String(value), filled: true };
}
function getContextMention(value: string): ContextMention | null {
const start = value.lastIndexOf('@');
if (start < 0) return null;
@ -554,6 +741,20 @@ function getPluginSourceLabel(plugin: InstalledPluginRecord): string {
return plugin.sourceKind === 'bundled' ? 'Official' : 'My plugin';
}
function getPluginQueryPreview(plugin: InstalledPluginRecord): string {
const raw = plugin.manifest?.od?.useCase?.query;
const value =
typeof raw === 'string'
? raw
: raw && typeof raw === 'object' && !Array.isArray(raw)
? raw.en ?? raw['zh-CN'] ?? Object.values(raw).find((entry): entry is string => (
typeof entry === 'string' && entry.length > 0
)) ?? ''
: '';
const trimmed = value.replace(/\s+/g, ' ').trim();
return trimmed.length > 96 ? `${trimmed.slice(0, 96)}` : trimmed;
}
interface RailGroupProps {
group: ChipGroup;
activeChipId: string | null;

View file

@ -10,6 +10,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
ApplyResult,
InputFieldSpec,
McpServerConfig,
InstalledPluginRecord,
ProjectKind,
@ -27,8 +28,10 @@ import type { Project, SkillSummary } from '../types';
import { HomeHero } from './HomeHero';
import { findChip, type HomeHeroChip } from './home-hero/chips';
import {
buildPluginAuthoringPrompt,
buildPluginAuthoringInputs,
buildPluginAuthoringPromptForInputs,
PLUGIN_AUTHORING_PROMPT,
PLUGIN_AUTHORING_PROMPT_TEMPLATE,
type HomePromptHandoff,
} from './home-hero/plugin-authoring';
import { PluginDetailsModal } from './PluginDetailsModal';
@ -45,6 +48,10 @@ interface ActivePlugin {
// is safe to render without a result.
result: ApplyResult | null;
inputs: Record<string, unknown>;
inputFields: InputFieldSpec[];
inputsValid: boolean;
queryTemplate: string | null;
lastRenderedPrompt: string | null;
// Stage B of plugin-driven-flow-plan: when the user applied this
// plugin through the Home chip rail, the chip carries the project
// kind we should stamp on the resulting create payload. `null` =
@ -54,6 +61,20 @@ interface ActivePlugin {
chipId: string | null;
}
interface SelectedPluginContext {
record: InstalledPluginRecord;
}
interface PendingReplacement {
title: string;
confirm: () => void;
}
interface PendingPluginUseHandoff {
pluginId: string;
inputs?: Record<string, unknown>;
}
const AUTHORING_DEFAULT_SCENARIO_INPUTS = {
artifactKind: 'Open Design plugin',
audience: 'Open Design plugin authors',
@ -66,6 +87,7 @@ interface Props {
onSubmit: (payload: PluginLoopSubmit) => void;
onOpenProject: (id: string) => void;
onViewAllProjects: () => void;
onBrowseRegistry?: () => void;
// Stage B: optional callbacks the rail's migration chips need.
// HomeView itself never imports them; EntryShell threads them
// through so the dispatcher can stay declarative.
@ -82,6 +104,7 @@ export function HomeView({
onSubmit,
onOpenProject,
onViewAllProjects,
onBrowseRegistry,
onImportFolder,
onOpenNewProject,
promptHandoff,
@ -95,14 +118,21 @@ export function HomeView({
const [pendingChipId, setPendingChipId] = useState<string | null>(null);
const [pendingAuthoringChipId, setPendingAuthoringChipId] = useState<string | null>(null);
const [pendingAuthoringPrompt, setPendingAuthoringPrompt] = useState(PLUGIN_AUTHORING_PROMPT);
const [pendingAuthoringInputs, setPendingAuthoringInputs] = useState<Record<string, unknown>>(
() => buildPluginAuthoringInputs(undefined),
);
const [pendingPluginUseHandoff, setPendingPluginUseHandoff] =
useState<PendingPluginUseHandoff | null>(null);
const [fallbackProjectKind, setFallbackProjectKind] = useState<ProjectKind | null>(null);
const [active, setActive] = useState<ActivePlugin | null>(null);
const [activeSkill, setActiveSkill] = useState<SkillSummary | null>(null);
const [selectedPluginContexts, setSelectedPluginContexts] = useState<SelectedPluginContext[]>([]);
const [mcpServers, setMcpServers] = useState<McpServerConfig[]>([]);
const [mcpLoading, setMcpLoading] = useState(true);
const [prompt, setPrompt] = useState('');
const [error, setError] = useState<string | null>(null);
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
const [pendingReplacement, setPendingReplacement] = useState<PendingReplacement | null>(null);
const inputRef = useRef<HTMLTextAreaElement | null>(null);
const consumedHandoffIdRef = useRef<number | null>(null);
@ -138,22 +168,34 @@ export function HomeView({
useEffect(() => {
if (!promptHandoff || consumedHandoffIdRef.current === promptHandoff.id) return;
consumedHandoffIdRef.current = promptHandoff.id;
setError(null);
if (promptHandoff.source === 'plugin-use') {
setPendingPluginUseHandoff({
pluginId: promptHandoff.pluginId,
...(promptHandoff.inputs ? { inputs: promptHandoff.inputs } : {}),
});
if (promptHandoff.focus) {
requestAnimationFrame(() => inputRef.current?.focus());
}
return;
}
setActive(null);
setActiveSkill(null);
setError(null);
setFallbackProjectKind(promptHandoff.source === 'plugin-authoring' ? 'other' : null);
setSelectedPluginContexts([]);
setFallbackProjectKind('other');
setPrompt(promptHandoff.prompt);
setPendingAuthoringPrompt(promptHandoff.prompt);
setPendingAuthoringInputs(promptHandoff.inputs);
if (promptHandoff.focus) {
requestAnimationFrame(() => inputRef.current?.focus());
}
if (promptHandoff.source === 'plugin-authoring') {
setPendingAuthoringChipId('plugin-authoring');
}
setPendingAuthoringChipId('create-plugin');
}, [promptHandoff]);
const contextItemCount = useMemo(
() => active?.result?.contextItems?.length ?? 0,
[active],
() => (active?.result?.contextItems?.length ?? 0) + selectedPluginContexts.length,
[active, selectedPluginContexts],
);
// When the active plugin was bound through a chip, the badge shows
@ -183,9 +225,28 @@ export function HomeView({
async function usePlugin(
record: InstalledPluginRecord,
nextPrompt?: string | null,
options?: { projectKind?: ProjectKind; chipId?: string; inputs?: Record<string, unknown> },
options?: {
projectKind?: ProjectKind;
chipId?: string;
inputs?: Record<string, unknown>;
queryTemplate?: string | null;
},
) {
setPendingApplyId(record.id);
const inputFields = record.manifest?.od?.inputs ?? [];
const optimisticInputs = hydratePluginInputs(inputFields, options?.inputs);
const inputsValid = pluginInputsAreValid(inputFields, optimisticInputs);
const queryTemplate =
options?.queryTemplate !== undefined
? options.queryTemplate
: nextPrompt !== undefined && nextPrompt !== null
? null
: resolvePluginQueryFallback(record.manifest?.od?.useCase?.query, locale) || null;
const optimisticPrompt =
nextPrompt !== undefined && nextPrompt !== null
? nextPrompt
: queryTemplate
? renderPluginBriefTemplate(queryTemplate, optimisticInputs)
: null;
if (options?.chipId) setPendingChipId(options.chipId);
setError(null);
// Optimistic update: the chip already carries the inputs and the
@ -196,21 +257,14 @@ export function HomeView({
// items in the background and we reconcile in place. Without this
// the user sees a ~100-500ms freeze before the input back-fills,
// which feels like the UI is jammed.
const optimisticInputs: Record<string, unknown> = { ...(options?.inputs ?? {}) };
const manifestQuery = resolvePluginQueryFallback(
record.manifest?.od?.useCase?.query,
locale,
);
const optimisticPrompt =
nextPrompt !== undefined && nextPrompt !== null
? nextPrompt
: manifestQuery
? renderPluginBriefTemplate(manifestQuery, optimisticInputs)
: null;
setActive({
record,
result: null,
inputs: optimisticInputs,
inputFields,
inputsValid,
queryTemplate,
lastRenderedPrompt: optimisticPrompt,
projectKind: options?.projectKind ?? null,
chipId: options?.chipId ?? null,
});
@ -219,15 +273,18 @@ export function HomeView({
if (optimisticPrompt !== null) setPrompt(optimisticPrompt);
requestAnimationFrame(() => inputRef.current?.focus());
const result = await applyPlugin(record.id, { locale, inputs: options?.inputs });
setPendingApplyId(null);
setPendingChipId(null);
if (!inputsValid) {
setPendingChipId(null);
return;
}
const result = await resolveActivePlugin(record, optimisticInputs);
if (!result) {
// Roll back the optimistic active so submit can't fire against a
// plugin that never bound. Only clear when the in-flight apply
// still matches the visible active state — concurrent clicks
// would otherwise stomp a successful later apply.
setActive((prev) => (prev?.record.id === record.id ? null : prev));
setActive((prev) => (prev?.record.id === record.id ? { ...prev, inputsValid: false } : prev));
setError(`Failed to apply ${record.title}. Make sure the daemon is reachable.`);
return;
}
@ -239,7 +296,13 @@ export function HomeView({
}
setActive((prev) =>
prev && prev.record.id === record.id
? { ...prev, result, inputs: reconciledInputs }
? {
...prev,
result,
inputs: reconciledInputs,
inputFields: result.inputs ?? inputFields,
inputsValid: pluginInputsAreValid(result.inputs ?? inputFields, reconciledInputs),
}
: prev,
);
// The daemon may have filled in `topic`/`audience` defaults the
@ -257,11 +320,151 @@ export function HomeView({
const reconciledPrompt = renderPluginBriefTemplate(reconciledQuery, reconciledInputs);
if (reconciledPrompt !== optimisticPrompt) {
setPrompt((current) => (current === optimisticPrompt ? reconciledPrompt : current));
setActive((prev) =>
prev && prev.record.id === record.id
? { ...prev, lastRenderedPrompt: reconciledPrompt }
: prev,
);
}
}
}
}
async function resolveActivePlugin(
record: InstalledPluginRecord,
inputs: Record<string, unknown>,
): Promise<ApplyResult | null> {
setPendingApplyId(record.id);
const result = await applyPlugin(record.id, { locale, inputs });
setPendingApplyId(null);
setPendingChipId(null);
return result;
}
function requestUsePlugin(
record: InstalledPluginRecord,
nextPrompt?: string | null,
options?: {
projectKind?: ProjectKind;
chipId?: string;
inputs?: Record<string, unknown>;
queryTemplate?: string | null;
},
) {
const replacement = previewPluginReplacement(record, nextPrompt, options?.inputs);
runWithReplacementConfirmation(record.title, replacement, () => {
void usePlugin(record, nextPrompt, options);
});
}
function runWithReplacementConfirmation(
title: string,
replacementPrompt: string | null,
confirm: () => void,
) {
if (
replacementPrompt !== null &&
prompt.trim().length > 0 &&
prompt.trim() !== replacementPrompt.trim()
) {
setPendingReplacement({ title, confirm });
return;
}
confirm();
}
function previewPluginReplacement(
record: InstalledPluginRecord,
nextPrompt?: string | null,
inputs?: Record<string, unknown>,
): string | null {
if (nextPrompt !== undefined && nextPrompt !== null) return nextPrompt;
const query = resolvePluginQueryFallback(record.manifest?.od?.useCase?.query, locale);
if (!query) return null;
return renderPluginBriefTemplate(query, hydratePluginInputs(record.manifest?.od?.inputs ?? [], inputs));
}
useEffect(() => {
if (!pendingPluginUseHandoff || pluginsLoading) return;
const record = plugins.find((plugin) => plugin.id === pendingPluginUseHandoff.pluginId);
setPendingPluginUseHandoff(null);
if (!record) {
setError(
`Plugin "${pendingPluginUseHandoff.pluginId}" is not installed. Refresh Plugins and try again.`,
);
return;
}
requestUsePlugin(
record,
undefined,
pendingPluginUseHandoff.inputs
? { inputs: pendingPluginUseHandoff.inputs }
: undefined,
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingPluginUseHandoff, pluginsLoading, plugins]);
function addPluginContext(record: InstalledPluginRecord, nextPrompt: string | null) {
setSelectedPluginContexts((prev) => {
if (prev.some((item) => item.record.id === record.id)) return prev;
return [...prev, { record }];
});
if (nextPrompt !== null) setPrompt(nextPrompt);
setError(null);
requestAnimationFrame(() => inputRef.current?.focus());
}
function removePluginContext(pluginId: string) {
setSelectedPluginContexts((prev) => prev.filter((item) => item.record.id !== pluginId));
}
function handlePromptChange(nextPrompt: string) {
setPrompt(nextPrompt);
if (!active?.queryTemplate) return;
const extracted = extractPluginInputsFromPrompt(
active.queryTemplate,
nextPrompt,
active.inputFields,
);
if (!extracted) return;
const nextInputs = { ...active.inputs, ...extracted };
const inputsValid = pluginInputsAreValid(active.inputFields, nextInputs);
const inputsChanged = !inputsEqual(active.inputs, nextInputs);
setActive({
...active,
inputs: nextInputs,
inputsValid,
result:
inputsChanged && !inputsEqual(active.result?.appliedPlugin?.inputs, nextInputs)
? null
: active.result,
lastRenderedPrompt: nextPrompt,
});
}
function updateActiveInputs(next: Record<string, unknown>) {
if (!active) return;
const inputsValid = pluginInputsAreValid(active.inputFields, next);
const nextRendered =
active.queryTemplate !== null
? renderPluginBriefTemplate(active.queryTemplate, next)
: active.lastRenderedPrompt;
if (
active.queryTemplate !== null &&
nextRendered !== null &&
(prompt === active.lastRenderedPrompt || prompt.trim().length === 0)
) {
setPrompt(nextRendered);
}
setActive({
...active,
inputs: next,
inputsValid,
result: inputsEqual(active.result?.appliedPlugin?.inputs, next) ? active.result : null,
lastRenderedPrompt: nextRendered,
});
}
function clearActivePlugin() {
setActive(null);
setFallbackProjectKind(null);
@ -283,15 +486,19 @@ export function HomeView({
}
function queuePluginAuthoring(chipId: string | null, goal?: string) {
const nextPrompt = goal ? buildPluginAuthoringPrompt(goal) : PLUGIN_AUTHORING_PROMPT;
setActive(null);
setActiveSkill(null);
setFallbackProjectKind('other');
setError(null);
setPrompt(nextPrompt);
setPendingAuthoringPrompt(nextPrompt);
setPendingAuthoringChipId(chipId ?? 'plugin-authoring');
requestAnimationFrame(() => inputRef.current?.focus());
const nextInputs = buildPluginAuthoringInputs(goal);
const nextPrompt = buildPluginAuthoringPromptForInputs(nextInputs);
runWithReplacementConfirmation('Plugin authoring', nextPrompt, () => {
setActive(null);
setActiveSkill(null);
setFallbackProjectKind('other');
setError(null);
setPrompt(nextPrompt);
setPendingAuthoringPrompt(nextPrompt);
setPendingAuthoringInputs(nextInputs);
setPendingAuthoringChipId(chipId ?? 'create-plugin');
requestAnimationFrame(() => inputRef.current?.focus());
});
}
useEffect(() => {
@ -309,11 +516,12 @@ export function HomeView({
}
void usePlugin(record, pendingAuthoringPrompt, {
projectKind: 'other',
chipId: pendingAuthoringChipId === 'plugin-authoring' ? undefined : pendingAuthoringChipId,
...(authoringRecord ? {} : { inputs: AUTHORING_DEFAULT_SCENARIO_INPUTS }),
chipId: pendingAuthoringChipId,
inputs: authoringRecord ? pendingAuthoringInputs : AUTHORING_DEFAULT_SCENARIO_INPUTS,
...(authoringRecord ? { queryTemplate: PLUGIN_AUTHORING_PROMPT_TEMPLATE } : {}),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingAuthoringChipId, pendingAuthoringPrompt, pluginsLoading, plugins]);
}, [pendingAuthoringChipId, pendingAuthoringPrompt, pendingAuthoringInputs, pluginsLoading, plugins]);
// Stage B of plugin-driven-flow-plan: the chip rail dispatcher.
// Pure UI-state mapping — the heavy lifting (apply / import) is
@ -333,7 +541,7 @@ export function HomeView({
);
return;
}
void usePlugin(record, undefined, {
requestUsePlugin(record, undefined, {
projectKind: chip.action.projectKind,
chipId: chip.id,
inputs: chip.action.inputs,
@ -363,19 +571,41 @@ export function HomeView({
}
}
function submit() {
async function submit() {
const trimmed = prompt.trim();
if (!trimmed) return;
let submittedActive = active;
if (submittedActive && !submittedActive.inputsValid) {
setError('Fill the required plugin parameters before running.');
return;
}
if (submittedActive && !submittedActive.result) {
const result = await resolveActivePlugin(submittedActive.record, submittedActive.inputs);
if (!result) {
setError(`Failed to apply ${submittedActive.record.title}. Check the plugin parameters and try again.`);
return;
}
submittedActive = { ...submittedActive, result };
setActive(submittedActive);
}
const contextPlugins = selectedPluginContexts.map((item) => ({
id: item.record.id,
title: item.record.title,
...(item.record.manifest?.description
? { description: item.record.manifest.description }
: {}),
}));
const defaultInputs = { prompt: trimmed };
onSubmit({
prompt: trimmed,
pluginId: active?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
pluginId: submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
skillId: activeSkill?.id ?? null,
appliedPluginSnapshotId: active?.result?.appliedPlugin?.snapshotId ?? null,
pluginTitle: active?.record.title ?? null,
taskKind: active?.result?.appliedPlugin?.taskKind ?? null,
pluginInputs: active ? active.inputs : defaultInputs,
projectKind: active?.projectKind ?? fallbackProjectKind ?? projectKindForSkill(activeSkill) ?? 'other',
appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null,
pluginTitle: submittedActive?.record.title ?? null,
taskKind: submittedActive?.result?.appliedPlugin?.taskKind ?? null,
pluginInputs: submittedActive ? submittedActive.inputs : defaultInputs,
projectKind: submittedActive?.projectKind ?? fallbackProjectKind ?? projectKindForSkill(activeSkill) ?? 'other',
contextPlugins,
});
}
@ -384,7 +614,7 @@ export function HomeView({
<HomeHero
ref={inputRef}
prompt={prompt}
onPromptChange={setPrompt}
onPromptChange={handlePromptChange}
onSubmit={submit}
activePluginTitle={activeBadgeTitle}
activeSkillId={activeSkill?.id ?? null}
@ -392,6 +622,16 @@ export function HomeView({
activeChipId={active?.chipId ?? null}
onClearActivePlugin={clearActivePlugin}
onClearActiveSkill={() => setActiveSkill(null)}
selectedPluginContexts={selectedPluginContexts.map((item) => item.record)}
onRemovePluginContext={removePluginContext}
onOpenPluginDetails={setDetailsRecord}
pluginInputFields={active?.inputFields ?? []}
pluginInputValues={active?.inputs ?? {}}
pluginInputTemplate={active?.queryTemplate ?? null}
onPluginInputValuesChange={updateActiveInputs}
onPluginInputValidityChange={(valid) => {
setActive((prev) => (prev ? { ...prev, inputsValid: valid } : prev));
}}
pluginOptions={plugins}
pluginsLoading={pluginsLoading}
skillOptions={selectableSkills}
@ -400,8 +640,12 @@ export function HomeView({
mcpLoading={mcpLoading}
pendingPluginId={pendingApplyId}
pendingChipId={pendingChipId}
submitDisabled={Boolean(pendingApplyId) || Boolean(pendingAuthoringChipId)}
onPickPlugin={(record, nextPrompt) => void usePlugin(record, nextPrompt)}
submitDisabled={
Boolean(pendingApplyId) ||
Boolean(pendingAuthoringChipId) ||
Boolean(active && !active.inputsValid)
}
onPickPlugin={(record, nextPrompt) => addPluginContext(record, nextPrompt)}
onPickSkill={useSkill}
onPickMcp={useMcpServer}
onPickChip={pickChip}
@ -421,19 +665,55 @@ export function HomeView({
loading={pluginsLoading}
activePluginId={active?.record.id ?? null}
pendingApplyId={pendingApplyId}
onUse={(record) => void usePlugin(record)}
onUse={(record) => requestUsePlugin(record)}
onOpenDetails={setDetailsRecord}
onCreatePlugin={(goal) => queuePluginAuthoring(null, goal)}
onBrowseRegistry={onBrowseRegistry}
/>
{detailsRecord ? (
<PluginDetailsModal
record={detailsRecord}
onClose={() => setDetailsRecord(null)}
onUse={(record) => void usePlugin(record)}
onUse={(record) => requestUsePlugin(record)}
isApplying={pendingApplyId === detailsRecord.id}
/>
) : null}
{pendingReplacement ? (
<div className="home-hero-confirm__backdrop" role="presentation">
<div
className="home-hero-confirm"
role="dialog"
aria-modal="true"
aria-labelledby="home-hero-confirm-title"
>
<h2 id="home-hero-confirm-title">Replace current prompt?</h2>
<p>
Using {pendingReplacement.title} will replace the text currently in the input.
</p>
<div className="home-hero-confirm__actions">
<button
type="button"
className="home-hero-confirm__secondary"
onClick={() => setPendingReplacement(null)}
>
Cancel
</button>
<button
type="button"
className="home-hero-confirm__primary"
onClick={() => {
const action = pendingReplacement.confirm;
setPendingReplacement(null);
action();
}}
>
Replace
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@ -448,3 +728,97 @@ function projectKindForSkill(skill: SkillSummary | null): ProjectKind | null {
if (skill.mode === 'audio' || skill.surface === 'audio') return 'audio';
return 'other';
}
function hydratePluginInputs(
fields: InputFieldSpec[],
provided: Record<string, unknown> | undefined,
): Record<string, unknown> {
const next: Record<string, unknown> = { ...(provided ?? {}) };
for (const field of fields) {
if (next[field.name] === undefined && field.default !== undefined) {
next[field.name] = field.default;
}
}
return next;
}
function pluginInputsAreValid(
fields: InputFieldSpec[],
values: Record<string, unknown>,
): boolean {
return fields.every((field) => {
if (!field.required) return true;
const value = values[field.name];
return value !== undefined && value !== null && value !== '';
});
}
const TEMPLATE_INPUT_PATTERN = /\{\{\s*([a-zA-Z_][\w-]*)\s*\}\}/g;
function extractPluginInputsFromPrompt(
template: string,
prompt: string,
fields: InputFieldSpec[],
): Record<string, unknown> | null {
TEMPLATE_INPUT_PATTERN.lastIndex = 0;
const fieldByName = new Map(fields.map((field) => [field.name, field]));
const keys: string[] = [];
let pattern = '^';
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = TEMPLATE_INPUT_PATTERN.exec(template)) !== null) {
const placeholder = match[0];
const key = match[1];
if (!key) continue;
pattern += escapeRegExp(template.slice(lastIndex, match.index));
pattern += '([\\s\\S]*?)';
keys.push(key);
lastIndex = match.index + placeholder.length;
}
if (keys.length === 0) return null;
pattern += escapeRegExp(template.slice(lastIndex));
const renderedMatch = new RegExp(pattern + '$').exec(prompt);
if (!renderedMatch) return null;
const next: Record<string, unknown> = {};
keys.forEach((key, index) => {
const field = fieldByName.get(key);
if (!field) return;
const raw = renderedMatch[index + 1] ?? '';
next[key] = coercePromptInputValue(raw, field);
});
return next;
}
function coercePromptInputValue(raw: string, field: InputFieldSpec): unknown {
const rawType = (field as { type?: unknown }).type;
const type = typeof rawType === 'string' ? rawType : 'string';
const trimmed = raw.trim();
if (type === 'number') {
if (trimmed.length === 0) return undefined;
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : raw;
}
if (type === 'boolean') {
if (trimmed.toLowerCase() === 'true') return true;
if (trimmed.toLowerCase() === 'false') return false;
}
if (type === 'select' && Array.isArray(field.options) && field.options.includes(trimmed)) {
return trimmed;
}
return raw;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function inputsEqual(
left: Record<string, unknown> | undefined,
right: Record<string, unknown>,
): boolean {
if (!left) return false;
const leftKeys = Object.keys(left).sort();
const rightKeys = Object.keys(right).sort();
if (leftKeys.length !== rightKeys.length) return false;
return leftKeys.every((key, idx) => key === rightKeys[idx] && left[key] === right[key]);
}

View file

@ -10,6 +10,8 @@
// - Select → native <select> with the supplied options.
// - Number → numeric input; coerces back to a number on blur.
// - Boolean → checkbox.
// - File → upload picker; the value stored for apply is lightweight
// metadata so project creation can still pass JSON cleanly.
// - Default values pre-fill the field on mount.
import { useEffect, useMemo, useState } from 'react';
@ -68,7 +70,13 @@ export function PluginInputsForm(props: Props) {
return (
<div className="plugin-inputs-form" data-testid="plugin-inputs-form">
{fields.map((field) => (
<label key={field.name} className="plugin-inputs-form__field">
<label
key={field.name}
className="plugin-inputs-form__field"
data-field-type={fieldType(field)}
data-required={field.required === true ? 'true' : 'false'}
data-filled={hasFieldValue(values[field.name]) ? 'true' : 'false'}
>
<span className="plugin-inputs-form__label">
{field.label ?? field.name}
{field.required ? <span className="plugin-inputs-form__required">*</span> : null}
@ -85,7 +93,8 @@ function renderField(
value: unknown,
onChange: (value: unknown) => void,
) {
if (field.type === 'select' && Array.isArray(field.options)) {
const type = fieldType(field);
if (type === 'select' && Array.isArray(field.options)) {
return (
<select
className="plugin-inputs-form__input"
@ -102,7 +111,7 @@ function renderField(
</select>
);
}
if (field.type === 'number') {
if (type === 'number') {
return (
<input
type="number"
@ -119,7 +128,7 @@ function renderField(
/>
);
}
if (field.type === 'boolean') {
if (type === 'boolean') {
return (
<input
type="checkbox"
@ -130,7 +139,27 @@ function renderField(
/>
);
}
if (field.type === 'text') {
if (type === 'file') {
const fileValue = fileInputLabel(value);
return (
<span className="plugin-inputs-form__file-shell">
<input
type="file"
className="plugin-inputs-form__input plugin-inputs-form__input--file"
onChange={(e) => {
const file = e.target.files?.[0];
onChange(file ? fileMetadata(file) : undefined);
}}
data-field-name={field.name}
{...(typeof field.accept === 'string' ? { accept: field.accept } : {})}
/>
<span className="plugin-inputs-form__file-label">
{fileValue ?? field.placeholder ?? 'Choose file…'}
</span>
</span>
);
}
if (type === 'text') {
return (
<textarea
className="plugin-inputs-form__input plugin-inputs-form__input--textarea"
@ -153,3 +182,28 @@ function renderField(
/>
);
}
function fieldType(field: InputFieldSpec): string {
const rawType = (field as { type?: unknown }).type;
const raw = typeof rawType === 'string' ? rawType : 'string';
return raw === 'upload' ? 'file' : raw;
}
function hasFieldValue(value: unknown): boolean {
return value !== undefined && value !== null && value !== '';
}
function fileMetadata(file: File) {
return {
name: file.name,
size: file.size,
type: file.type,
lastModified: file.lastModified,
};
}
function fileInputLabel(value: unknown): string | null {
if (!value || typeof value !== 'object') return null;
const name = (value as { name?: unknown }).name;
return typeof name === 'string' && name.length > 0 ? name : null;
}

View file

@ -22,6 +22,7 @@ export interface PluginLoopSubmit {
pluginTitle: string | null;
taskKind: string | null;
pluginInputs?: Record<string, unknown> | null;
contextPlugins?: Array<{ id: string; title: string; description?: string }> | null;
// Stage B of plugin-driven-flow-plan: when the user picked a Home
// chip the rail tells the submit handler which `ProjectKind` to
// stamp on the new project's metadata. The daemon-side default

View file

@ -38,6 +38,7 @@ interface Props {
action: PluginShareAction,
) => void;
onCreatePlugin?: (goal?: string) => void;
onBrowseRegistry?: () => void;
title?: string;
subtitle?: string;
emptyMessage?: string;
@ -55,8 +56,9 @@ export function PluginsHomeSection({
onOpenDetails,
onPluginShareAction,
onCreatePlugin,
title = 'Official',
subtitle = 'First-party Open Design workflows packaged as plugins. Pick one to load a starter prompt, or type freely above.',
onBrowseRegistry,
title = 'Official starters',
subtitle = 'Ready-to-use Open Design workflows bundled with this runtime. Pick one to load a starter prompt, or browse the registry for more.',
emptyMessage = 'Catalog is empty. Bundled plugins ship with Open Design and should appear here automatically — try restarting the daemon if this persists.',
}: Props) {
const {
@ -92,6 +94,16 @@ export function PluginsHomeSection({
</p>
</div>
<div className="plugins-home__head-tools">
{onBrowseRegistry ? (
<button
type="button"
className="plugins-home__linkbtn"
onClick={onBrowseRegistry}
data-testid="plugins-home-browse-registry"
>
Browse registry
</button>
) : null}
<SearchInput value={query} onChange={setQuery} />
<span className="plugins-home__count">
{loading ? '…' : `${filtered.length} of ${totalVisible}`}

View file

@ -45,12 +45,15 @@ const PLUGINS_TABS: ReadonlyArray<{
label: string;
hint: string;
}> = [
{ id: 'installed', label: 'Installed', hint: 'Ready to use' },
{ id: 'installed', label: 'Installed', hint: 'Your plugins' },
{ id: 'available', label: 'Available', hint: 'From sources' },
{ id: 'sources', label: 'Sources', hint: 'Catalogs' },
{ id: 'team', label: 'Team', hint: 'Enterprise' },
];
const COMMUNITY_MARKETPLACE_SOURCE_URL =
'https://raw.githubusercontent.com/nexu-io/open-design/garnet-hemisphere/plugins/registry/community/open-design-marketplace.json';
const PLUGIN_SHARE_DETAILS: Record<PluginShareAction, {
eyebrow: string;
fallbackTitle: string;
@ -86,6 +89,7 @@ const PLUGIN_SHARE_DETAILS: Record<PluginShareAction, {
interface PluginsViewProps {
onCreatePlugin?: (goal?: string) => void;
onUsePlugin?: (record: InstalledPluginRecord) => void;
onCreatePluginShareProject?: (
pluginId: string,
action: PluginShareAction,
@ -95,6 +99,7 @@ interface PluginsViewProps {
export function PluginsView({
onCreatePlugin,
onUsePlugin,
onCreatePluginShareProject,
}: PluginsViewProps) {
const { locale } = useI18n();
@ -136,10 +141,6 @@ export function PluginsView({
return () => window.removeEventListener('open-design:plugins-changed', refresh);
}, []);
const officialPlugins = useMemo(
() => plugins.filter((plugin) => plugin.sourceKind === 'bundled'),
[plugins],
);
const userPlugins = useMemo(
() => plugins.filter((plugin) => USER_SOURCE_KINDS.has(plugin.sourceKind)),
[plugins],
@ -165,6 +166,11 @@ export function PluginsView({
}
async function handleUsePlugin(record: InstalledPluginRecord) {
if (onUsePlugin) {
setDetailsRecord(null);
onUsePlugin(record);
return;
}
setPendingApplyId(record.id);
setNotice(null);
const result = await applyPlugin(record.id, { locale });
@ -281,7 +287,7 @@ export function PluginsView({
</header>
<div className="plugins-view__stats" aria-label="Plugin summary">
<StatCard label="Installed" value={plugins.length} />
<StatCard label="Installed" value={userPlugins.length} />
<StatCard label="Available" value={availablePlugins.length} />
<StatCard label="Sources" value={marketplaces.length} />
</div>
@ -317,37 +323,22 @@ export function PluginsView({
{loading ? <div className="plugins-view__empty">Loading plugins</div> : null}
{!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."
/>
</>
<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="Installed plugins"
subtitle="Plugins you imported or installed from marketplace sources."
emptyMessage="No installed user plugins yet. Use Create / Import or install an Available entry."
/>
) : null}
{!loading && activeTab === 'available' ? (
@ -484,7 +475,7 @@ function PluginShareConfirmModal({
aria-label="Close share confirmation"
title="Close"
>
<Icon name="close" size={14} />
<Icon name="close" size={18} />
</button>
</header>
@ -774,7 +765,7 @@ function SourcesPanel({
id="plugin-marketplace-url"
value={url}
onChange={(event) => setUrl(event.target.value)}
placeholder="https://open-design.ai/marketplace/open-design-marketplace.json"
placeholder={COMMUNITY_MARKETPLACE_SOURCE_URL}
disabled={pendingAction === 'add'}
/>
<select
@ -854,7 +845,7 @@ function SourcesPanel({
);
}
type ImportKind = 'github' | 'zip' | 'folder' | 'template';
type ImportKind = 'github' | 'zip' | 'folder';
function PluginImportModal({
onClose,
@ -906,7 +897,7 @@ function PluginImportModal({
<header className="plugins-import-modal__head">
<div>
<p className="plugins-view__kicker">User plugins</p>
<h2 id="plugins-import-title">Create or import a plugin</h2>
<h2 id="plugins-import-title">Import a plugin</h2>
</div>
<button
type="button"
@ -940,13 +931,6 @@ function PluginImportModal({
body="Upload a plugin directory."
onClick={() => setKind('folder')}
/>
<ImportChoice
active={kind === 'template'}
icon="edit"
title="Create from template"
body="Coming soon."
onClick={() => setKind('template')}
/>
</nav>
<div className="plugins-import-modal__body">
@ -1007,21 +991,6 @@ function PluginImportModal({
/>
) : null}
{kind === 'template' ? (
<section className="plugins-import-modal__coming">
<span className="plugins-view__future-icon" aria-hidden>
<Icon name="edit" size={18} />
</span>
<div>
<p className="plugins-view__kicker">Coming soon</p>
<h3>Create from template</h3>
<p>
Template authoring will scaffold manifest metadata, examples,
preview assets, and starter instructions in a future pass.
</p>
</div>
</section>
) : null}
</div>
<footer className="plugins-import-modal__foot">
@ -1050,7 +1019,7 @@ function ImportChoice({
onClick,
}: {
active: boolean;
icon: 'github' | 'upload' | 'folder' | 'edit';
icon: 'github' | 'upload' | 'folder';
title: string;
body: string;
onClick: () => void;
@ -1155,6 +1124,9 @@ 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));
if (plugin.sourceMarketplaceEntryName) {
keys.add(normalizePluginName(plugin.sourceMarketplaceEntryName));
}
return Array.from(keys);
}

View file

@ -1,42 +1,97 @@
export interface HomePromptHandoff {
id: number;
prompt: string;
focus: boolean;
source: 'plugin-authoring';
}
export type HomePromptHandoff =
| {
id: number;
prompt: string;
focus: boolean;
source: 'plugin-authoring';
goal: string;
inputs: Record<string, unknown>;
queryTemplate: string;
}
| {
id: number;
pluginId: string;
focus: boolean;
source: 'plugin-use';
inputs?: Record<string, unknown>;
};
export const PLUGIN_AUTHORING_PROMPT = [
'Create an Open Design plugin for: <describe the workflow you want to package>.',
export const PLUGIN_AUTHORING_GOAL_INPUT = 'pluginGoal';
export const PLUGIN_AUTHORING_DEFAULT_GOAL = "a reusable workflow described by the user's prompt";
export const PLUGIN_AUTHORING_PROMPT_TEMPLATE = [
`Create an Open Design plugin for: {{${PLUGIN_AUTHORING_GOAL_INPUT}}}.`,
'',
'Follow docs/plugins-spec.md and produce a folder named generated-plugin with:',
'Run the agent-assisted plugin authoring flow end to end. Follow docs/plugins-spec.md and produce a folder named generated-plugin with:',
'- SKILL.md describing the agent behavior and workflow',
'- open-design.json with valid metadata, mode, task kind, inputs, and any pipeline/context references',
'- open-design.json with valid metadata, vendor/plugin-name naming when publishing, plugin.repo, mode, task kind, inputs, and any pipeline/context references',
'- optional examples/ and assets/ when useful',
'',
'When finished, summarize the files created and whether the folder is ready to add to My plugins.',
'Then run or prepare the CLI path: od plugin validate, od plugin pack, local install/run validation, od plugin whoami/login through gh, and od plugin publish when the user is ready to open a registry PR.',
'',
'When finished, summarize files created, validation status, local install/run status, pack output, and the exact publish command or PR next step.',
].join('\n');
export function buildPluginAuthoringPrompt(goal: string): string {
export const PLUGIN_AUTHORING_PROMPT = buildPluginAuthoringPrompt(PLUGIN_AUTHORING_DEFAULT_GOAL);
export function buildPluginAuthoringPrompt(goal: string | undefined): string {
const normalizedGoal = normalizePluginAuthoringGoal(goal);
return PLUGIN_AUTHORING_PROMPT_TEMPLATE.replace(
`{{${PLUGIN_AUTHORING_GOAL_INPUT}}}`,
normalizedGoal,
);
}
export function normalizePluginAuthoringGoal(goal: string | undefined): string {
const trimmed = goal?.trim();
return trimmed && trimmed.length > 0 ? trimmed : PLUGIN_AUTHORING_DEFAULT_GOAL;
}
export function buildPluginAuthoringInputs(goal: string | undefined): Record<string, unknown> {
return { [PLUGIN_AUTHORING_GOAL_INPUT]: normalizePluginAuthoringGoal(goal) };
}
export function buildPluginAuthoringPromptForInputs(inputs: Record<string, unknown>): string {
const value = inputs[PLUGIN_AUTHORING_GOAL_INPUT];
return buildPluginAuthoringPrompt(typeof value === 'string' ? value : undefined);
}
function createPluginAuthoringPayload(goal: string | undefined) {
const normalizedGoal = normalizePluginAuthoringGoal(goal);
const inputs = buildPluginAuthoringInputs(normalizedGoal);
return [
`Create an Open Design plugin for: ${goal}`,
'',
'Follow docs/plugins-spec.md and produce a folder named generated-plugin with:',
'- SKILL.md describing the agent behavior and workflow',
'- open-design.json with valid metadata, mode, task kind, inputs, and any pipeline/context references',
'- optional examples/ and assets/ when useful',
'',
'When finished, summarize the files created and whether the folder is ready to add to My plugins.',
].join('\n');
normalizedGoal,
inputs,
buildPluginAuthoringPromptForInputs(inputs),
] as const;
}
export function createPluginAuthoringHandoff(
id: number,
prompt = PLUGIN_AUTHORING_PROMPT,
goal?: string,
): HomePromptHandoff {
const [normalizedGoal, inputs, prompt] = createPluginAuthoringPayload(goal);
return {
id,
prompt,
focus: true,
source: 'plugin-authoring',
goal: normalizedGoal,
inputs,
queryTemplate: PLUGIN_AUTHORING_PROMPT_TEMPLATE,
};
}
export function createPluginUseHandoff(
id: number,
pluginId: string,
inputs?: Record<string, unknown>,
): HomePromptHandoff {
return {
id,
pluginId,
...(inputs ? { inputs } : {}),
focus: true,
source: 'plugin-use',
};
}

View file

@ -113,7 +113,7 @@ export function PluginScenarioDetail({
aria-label="Close details"
title="Close (Esc)"
>
<Icon name="close" size={14} />
<Icon name="close" size={18} />
</button>
</div>
</header>

View file

@ -60,12 +60,13 @@ function buildInstallCommand(record: InstalledPluginRecord): string {
// The daemon's install resolver accepts the raw `record.source`
// shape for every kind (github:owner/repo[@ref][/sub], https URL,
// local path, marketplace id), so we mirror it verbatim. For
// marketplace records the marketplace id is shorter / friendlier
// when present.
if (
record.sourceKind === 'marketplace' &&
typeof record.sourceMarketplaceId === 'string'
) {
// marketplace records should use the registry entry name when
// provenance preserved it; sourceMarketplaceId names the catalog,
// not the plugin package.
if (typeof record.sourceMarketplaceEntryName === 'string') {
return `od plugin install ${record.sourceMarketplaceEntryName}`;
}
if (record.sourceKind === 'marketplace' && typeof record.sourceMarketplaceId === 'string') {
return `od plugin install ${record.sourceMarketplaceId}`;
}
return `od plugin install ${record.source}`;

View file

@ -18808,8 +18808,8 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
background: transparent;
border: 1px solid transparent;
color: var(--text-muted);
width: 28px;
height: 28px;
width: 40px;
height: 40px;
border-radius: var(--radius-sm, 6px);
display: inline-flex;
align-items: center;
@ -18817,6 +18817,11 @@ body.entry-resizing { cursor: col-resize; user-select: none; }
cursor: pointer;
flex-shrink: 0;
}
.plugin-details-modal__close svg {
width: 18px;
height: 18px;
stroke-width: 2;
}
.plugin-details-modal__close:hover,
.plugin-details-modal__close:focus-visible {
background: var(--bg-subtle);

View file

@ -743,6 +743,44 @@ export interface PluginMarketplaceEntry {
source: string;
version?: string;
ref?: string;
dist?: {
type?: string;
archive?: string;
integrity?: string;
manifestDigest?: string;
};
versions?: Array<{
version: string;
source?: string;
ref?: string;
dist?: {
type?: string;
archive?: string;
integrity?: string;
manifestDigest?: string;
};
integrity?: string;
manifestDigest?: string;
deprecated?: boolean | string;
yanked?: boolean;
yankedAt?: string;
yankReason?: string;
}>;
distTags?: Record<string, string>;
integrity?: string;
manifestDigest?: string;
publisher?: {
id?: string;
github?: string;
url?: string;
};
homepage?: string;
license?: string;
capabilitiesSummary?: string[];
deprecated?: boolean | string;
yanked?: boolean;
yankedAt?: string;
yankReason?: string;
tags?: string[];
title?: string;
description?: string;

View file

@ -120,6 +120,27 @@
background: color-mix(in srgb, var(--bg-subtle) 86%, var(--accent));
color: var(--text-strong);
}
.home-hero__active-chip--context {
background: var(--bg-subtle);
color: var(--text);
border-color: var(--border-soft);
}
.home-hero__active-chip-body {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.home-hero__active-chip-body:hover {
color: var(--accent);
}
.home-hero__active-dot {
width: 7px;
height: 7px;
@ -143,7 +164,61 @@
.home-hero__context-summary {
color: var(--text-muted);
}
.home-hero__prompt-surface {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
border-radius: calc(var(--radius-lg) - 6px);
}
.home-hero__prompt-surface--with-inputs {
padding: 8px;
border: 1px solid color-mix(in srgb, var(--accent) 34%, var(--border-soft));
background:
linear-gradient(
180deg,
color-mix(in srgb, var(--accent) 6%, transparent),
color-mix(in srgb, var(--accent) 2%, transparent)
);
}
.home-hero__prompt-editor {
position: relative;
min-width: 0;
}
.home-hero__prompt-highlight {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
padding: 6px;
color: var(--text);
font: inherit;
font-size: 15px;
line-height: 1.55;
pointer-events: none;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.home-hero__prompt-slot {
display: inline;
margin: 0 1px;
padding: 1px 5px 2px;
border: 1px solid color-mix(in srgb, var(--accent) 34%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--accent) 14%, var(--bg-panel));
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
color: var(--accent);
font-weight: 680;
}
.home-hero__prompt-slot[data-filled='false'] {
border-style: dashed;
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
color: color-mix(in srgb, var(--accent) 74%, var(--text-muted));
}
.home-hero__input {
position: relative;
z-index: 1;
width: 100%;
min-height: 84px;
resize: vertical;
@ -157,6 +232,14 @@
color: var(--text);
transition: none;
}
.home-hero__prompt-editor--highlighted .home-hero__input {
color: transparent;
caret-color: var(--text);
}
.home-hero__prompt-editor--highlighted .home-hero__input::selection {
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: transparent;
}
.home-hero__input:focus {
border: none;
outline: none;
@ -165,6 +248,94 @@
.home-hero__input::placeholder {
color: var(--text-soft);
}
.home-hero__input-card .plugin-inputs-form {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
padding-top: 8px;
border-top: 1px dashed color-mix(in srgb, var(--accent) 36%, var(--border-soft));
}
.home-hero__input-card .plugin-inputs-form__field {
min-width: 0;
gap: 6px;
padding: 9px 10px;
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
border-radius: 8px;
background: color-mix(in srgb, var(--accent-tint) 62%, var(--bg-panel));
box-shadow: inset 0 1px 0 color-mix(in srgb, white 42%, transparent);
}
.home-hero__input-card .plugin-inputs-form__field[data-filled='true'] {
border-color: color-mix(in srgb, var(--accent) 52%, var(--border));
background: color-mix(in srgb, var(--accent-tint) 78%, var(--bg-panel));
}
.home-hero__input-card .plugin-inputs-form__label {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
gap: 4px;
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--accent) 10%, var(--bg-panel));
color: var(--accent);
font-size: 11.5px;
font-weight: 650;
}
.home-hero__input-card .plugin-inputs-form__required {
color: var(--accent);
}
.home-hero__input-card .plugin-inputs-form__input {
width: 100%;
min-width: 0;
border-color: color-mix(in srgb, var(--accent) 22%, var(--border));
background: var(--bg-panel);
font-size: 13px;
}
.home-hero__input-card .plugin-inputs-form__input:focus {
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
.home-hero__input-card .plugin-inputs-form__input--textarea {
min-height: 64px;
}
.home-hero__input-card .plugin-inputs-form__field[data-field-type='boolean'] {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
}
.home-hero__input-card .plugin-inputs-form__field[data-field-type='boolean'] .plugin-inputs-form__input {
width: 18px;
height: 18px;
accent-color: var(--accent);
}
.home-hero__input-card .plugin-inputs-form__file-shell {
position: relative;
display: block;
min-width: 0;
}
.home-hero__input-card .plugin-inputs-form__input--file {
position: absolute;
inset: 0;
opacity: 0;
cursor: pointer;
}
.home-hero__input-card .plugin-inputs-form__file-label {
display: block;
overflow: hidden;
width: 100%;
padding: 7px 9px;
border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border));
border-radius: var(--radius-sm, 6px);
background: var(--bg-panel);
color: var(--text);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-hero__input-card .plugin-inputs-form__file-shell:focus-within .plugin-inputs-form__file-label {
outline: 2px solid color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
.home-hero__plugin-picker {
position: absolute;
left: 14px;
@ -182,6 +353,69 @@
background: var(--bg-panel);
box-shadow: var(--shadow-lg, 0 14px 36px rgba(0, 0, 0, 0.14));
}
.home-hero__plugin-hover-card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px 14px;
align-items: center;
margin-top: 4px;
padding: 10px;
border: 1px solid var(--border-soft);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--bg-subtle) 72%, var(--bg-panel));
}
.home-hero__plugin-hover-card strong {
display: block;
overflow: hidden;
color: var(--text-strong);
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-hero__plugin-hover-card p {
margin: 3px 0 0;
color: var(--text-muted);
font-size: 11.5px;
line-height: 1.4;
}
.home-hero__plugin-hover-kicker {
display: block;
margin-bottom: 2px;
color: var(--text-faint);
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
}
.home-hero__plugin-hover-meta {
grid-column: 1 / -1;
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
color: var(--text-faint);
font-size: 11px;
}
.home-hero__plugin-hover-meta span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-hero__plugin-hover-card button {
appearance: none;
padding: 6px 9px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-panel);
color: var(--text);
font: inherit;
font-size: 11.5px;
cursor: pointer;
}
.home-hero__plugin-hover-card button:hover {
border-color: var(--border-strong);
color: var(--accent);
}
.home-hero__mention-tabs {
display: flex;
gap: 4px;
@ -347,6 +581,69 @@
width: 100%;
}
.home-hero-confirm__backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(16, 12, 10, 0.34);
backdrop-filter: blur(8px);
}
.home-hero-confirm {
width: min(420px, 100%);
padding: 18px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
box-shadow: var(--shadow-lg, 0 18px 50px rgba(0, 0, 0, 0.18));
}
.home-hero-confirm h2 {
margin: 0;
color: var(--text-strong);
font-size: 16px;
letter-spacing: 0;
}
.home-hero-confirm p {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.5;
}
.home-hero-confirm__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.home-hero-confirm__secondary,
.home-hero-confirm__primary {
appearance: none;
padding: 8px 12px;
border-radius: var(--radius-sm);
font: inherit;
font-size: 12.5px;
cursor: pointer;
}
.home-hero-confirm__secondary {
border: 1px solid var(--border);
background: var(--bg-panel);
color: var(--text);
}
.home-hero-confirm__primary {
border: 1px solid var(--accent);
background: var(--accent);
color: white;
}
.home-hero-confirm__secondary:hover {
border-color: var(--border-strong);
}
.home-hero-confirm__primary:hover {
background: var(--accent-strong);
}
/* ------------------------------------------------------------
Stage B of plugin-driven-flow-plan: Home intent rail.
Two flex sub-groups ("create" outputs vs. lower starter shortcuts)

View file

@ -402,12 +402,27 @@
.plugins-view__source-row {
display: flex;
align-items: stretch;
gap: 8px;
min-width: 0;
}
.plugins-view__source-row input {
flex: 1 1 auto;
flex: 1 1 0;
min-width: 0;
width: auto;
}
.plugins-view__source-row select {
flex: 0 0 auto;
min-width: 140px;
width: 180px;
}
.plugins-view__source-row .plugins-view__primary {
flex: 0 0 auto;
min-width: 104px;
white-space: nowrap;
}
.plugins-view__source-help {
@ -537,7 +552,6 @@
}
.plugins-import-modal__head h2,
.plugins-import-modal__coming h3,
.plugins-view__install-card h3 {
margin: 0;
color: var(--text-strong);
@ -565,7 +579,7 @@
.plugins-import-modal__tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
@ -646,16 +660,6 @@
display: none;
}
.plugins-import-modal__coming {
display: flex;
gap: 12px;
padding: 16px;
border: 1px dashed var(--border);
border-radius: var(--radius);
background: var(--bg-subtle);
}
.plugins-import-modal__coming p,
.plugins-view__install-card p,
.plugins-import-modal__foot p {
margin: 4px 0 0;
@ -696,6 +700,13 @@
flex-direction: column;
}
.plugins-view__source-row input,
.plugins-view__source-row select,
.plugins-view__source-row .plugins-view__primary {
flex: 0 0 auto;
width: 100%;
}
.plugins-import-modal__tabs {
grid-template-columns: 1fr;
}

View file

@ -3,6 +3,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
InputFieldSpec,
InstalledPluginRecord,
McpServerConfig,
PluginSourceKind,
@ -262,4 +263,69 @@ describe('HomeHero plugin picker', () => {
expect(onPickPlugin).not.toHaveBeenCalled();
expect(onSubmit).not.toHaveBeenCalled();
});
it('highlights rendered plugin input values inside the prompt surface', () => {
const fields: InputFieldSpec[] = [
{
name: 'source',
label: 'Import source',
type: 'select',
options: ['folder', 'zip', 'github', 'marketplace'],
default: 'marketplace',
},
];
const prompt =
'Create a compact import receipt for community-import-smoke-test installed from marketplace.';
const { rerender } = render(
<HomeHero
prompt={prompt}
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Community Import Smoke Test"
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginInputFields={fields}
pluginInputValues={{ source: 'marketplace' }}
pluginInputTemplate="Create a compact import receipt for community-import-smoke-test installed from {{source}}."
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
const slot = screen.getByTestId('home-hero-prompt-slot-source');
expect(slot.textContent).toBe('marketplace');
expect(slot.getAttribute('data-filled')).toBe('true');
expect(screen.getByDisplayValue('marketplace')).toBeTruthy();
rerender(
<HomeHero
prompt={`${prompt} Extra user edit.`}
onPromptChange={() => undefined}
onSubmit={() => undefined}
activePluginTitle="Community Import Smoke Test"
activeChipId={null}
onClearActivePlugin={() => undefined}
pluginInputFields={fields}
pluginInputValues={{ source: 'marketplace' }}
pluginInputTemplate="Create a compact import receipt for community-import-smoke-test installed from {{source}}."
pluginOptions={[]}
pluginsLoading={false}
pendingPluginId={null}
pendingChipId={null}
onPickPlugin={() => undefined}
onPickChip={() => undefined}
contextItemCount={0}
error={null}
/>,
);
expect(screen.queryByTestId('home-hero-prompt-slot-source')).toBeNull();
});
});

View file

@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
type InstalledPluginRecord,
type SkillSummary,
} from '@open-design/contracts';
import { HomeView } from '../../src/components/HomeView';
@ -23,12 +24,109 @@ const SKILL: SkillSummary = {
aggregatesExamples: false,
};
function makePlugin(id: string, title: string): InstalledPluginRecord {
return {
id,
title,
version: '1.0.0',
sourceKind: 'bundled',
source: `/tmp/${id}`,
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
fsPath: `/tmp/${id}`,
installedAt: 0,
updatedAt: 0,
manifest: {
name: id,
title,
version: '1.0.0',
description: `${title} fixture`,
tags: ['fixture'],
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: `Hydrated query from ${title}`,
},
},
},
};
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
describe('HomeView context picker', () => {
it('adds multiple @ plugins as context without applying or hydrating their query', async () => {
const plugins = [
makePlugin('chart-plugin', 'Chart Plugin'),
makePlugin('deck-plugin', 'Deck Plugin'),
];
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url === '/api/mcp/servers') {
return new Response(JSON.stringify({ servers: [], templates: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
fireEvent.change(input, { target: { value: 'Build @chart' } });
fireEvent.mouseDown(await screen.findByRole('option', { name: /chart plugin/i }));
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('Build');
expect(screen.getByTestId('home-hero-context-plugin-chart-plugin')).toBeTruthy();
});
fireEvent.change(input, { target: { value: 'Build @deck' } });
fireEvent.mouseDown(await screen.findByRole('option', { name: /deck plugin/i }));
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('Build');
expect(screen.getByTestId('home-hero-context-plugin-chart-plugin')).toBeTruthy();
expect(screen.getByTestId('home-hero-context-plugin-deck-plugin')).toBeTruthy();
});
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
expect((input as HTMLTextAreaElement).value).not.toContain('Hydrated query');
fireEvent.click(screen.getByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: 'Build',
pluginId: DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID,
contextPlugins: [
expect.objectContaining({ id: 'chart-plugin', title: 'Chart Plugin' }),
expect.objectContaining({ id: 'deck-plugin', title: 'Deck Plugin' }),
],
}));
});
it('binds a selected home skill to the created project payload', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {

View file

@ -5,6 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { HomeView } from '../../src/components/HomeView';
import {
createPluginAuthoringHandoff,
createPluginUseHandoff,
PLUGIN_AUTHORING_DEFAULT_GOAL,
PLUGIN_AUTHORING_PROMPT,
} from '../../src/components/home-hero/plugin-authoring';
@ -27,7 +29,16 @@ const AUTHORING_PLUGIN = {
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: { query: 'Create a plugin.' },
useCase: { query: 'Create an Open Design plugin for {{pluginGoal}}.' },
inputs: [
{
name: 'pluginGoal',
type: 'string',
required: false,
default: PLUGIN_AUTHORING_DEFAULT_GOAL,
label: 'Plugin goal',
},
],
},
},
};
@ -85,7 +96,7 @@ const WEB_PROTOTYPE_PLUGIN = {
const AUTHORING_APPLY_RESULT = {
query: 'Create a plugin.',
contextItems: [],
inputs: [],
inputs: AUTHORING_PLUGIN.manifest.od.inputs,
assets: [],
mcpServers: [],
trust: 'trusted',
@ -96,7 +107,7 @@ const AUTHORING_APPLY_RESULT = {
pluginId: 'od-plugin-authoring',
pluginVersion: '0.1.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
inputs: { pluginGoal: PLUGIN_AUTHORING_DEFAULT_GOAL },
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
@ -200,6 +211,46 @@ describe('HomeView prompt handoff', () => {
expect(screen.queryByRole('alert')).toBeNull();
});
it('applies a plugin-use handoff from the Plugins page', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
promptHandoff={createPluginUseHandoff(1, 'example-web-prototype')}
/>,
);
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
expect.anything(),
));
expect((await screen.findByTestId('home-hero-input') as HTMLTextAreaElement).value)
.toBe('Create a plugin.');
});
it('routes free-form submits through the hidden default plugin without applying a visible chip', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
@ -354,6 +405,107 @@ describe('HomeView prompt handoff', () => {
expect(screen.queryByRole('alert')).toBeNull();
});
it('confirms before an explicit plugin use replaces an existing prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
fireEvent.click(await screen.findByTestId('home-hero-rail-prototype'));
expect(await screen.findByRole('dialog', { name: /replace current prompt/i })).toBeTruthy();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
expect.anything(),
));
});
it('confirms before a plugin-use handoff replaces an existing prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [WEB_PROTOTYPE_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return new Response(JSON.stringify(DEFAULT_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const { rerender } = render(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
const input = await screen.findByTestId('home-hero-input');
fireEvent.change(input, { target: { value: 'Keep my current brief' } });
rerender(
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
promptHandoff={createPluginUseHandoff(2, 'example-web-prototype')}
/>,
);
expect(await screen.findByRole('dialog', { name: /replace current prompt/i })).toBeTruthy();
expect(fetchMock.mock.calls.some(([url]) => String(url).includes('/apply'))).toBe(false);
fireEvent.click(screen.getByRole('button', { name: 'Replace' }));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/example-web-prototype/apply',
expect.anything(),
));
});
it('binds od-plugin-authoring before submitting the rail create-plugin prompt', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
@ -391,17 +543,80 @@ describe('HomeView prompt handoff', () => {
'/api/plugins/od-plugin-authoring/apply',
expect.anything(),
));
await waitFor(() => {
const badge = screen.getByTestId('home-hero-active-plugin');
expect(badge.textContent).toContain('Create plugin');
expect(badge.textContent).not.toContain('Plugin authoring');
});
fireEvent.click(await screen.findByTestId('home-hero-submit'));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: PLUGIN_AUTHORING_PROMPT,
pluginId: 'od-plugin-authoring',
appliedPluginSnapshotId: 'snap-authoring',
pluginInputs: {},
pluginInputs: { pluginGoal: PLUGIN_AUTHORING_DEFAULT_GOAL },
projectKind: 'other',
}));
});
it('keeps the authoring goal input linked to the prompt and submit payload', async () => {
const fetchMock = vi.fn<typeof fetch>(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [AUTHORING_PLUGIN] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return new Response(JSON.stringify(AUTHORING_APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
cb(0);
return 0;
});
const onSubmit = vi.fn();
render(
<HomeView
projects={[]}
onSubmit={onSubmit}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>,
);
fireEvent.click(await screen.findByTestId('home-hero-rail-create-plugin'));
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(
'/api/plugins/od-plugin-authoring/apply',
expect.anything(),
));
const input = screen.getByTestId('home-hero-input') as HTMLTextAreaElement;
const goalInput = await screen.findByLabelText(/plugin goal/i);
fireEvent.change(goalInput, {
target: { value: 'turn support transcripts into triaged GitHub issues' },
});
await waitFor(() => {
expect(input.value).toContain('turn support transcripts into triaged GitHub issues');
});
fireEvent.click(screen.getByTestId('home-hero-submit'));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({
prompt: expect.stringContaining('turn support transcripts into triaged GitHub issues'),
pluginId: 'od-plugin-authoring',
pluginInputs: {
pluginGoal: 'turn support transcripts into triaged GitHub issues',
},
})));
});
it('does not submit the create-plugin prompt before the authoring scenario is applied', async () => {
let resolveApply: (response: Response) => void = () => undefined;
const applyResponse = new Promise<Response>((resolve) => {

View file

@ -74,4 +74,28 @@ describe('PluginInputsForm', () => {
expect(screen.getByText('VC')).toBeTruthy();
expect(screen.getByText('Customer')).toBeTruthy();
});
it('renders file inputs as upload slots with serializable metadata', () => {
render(
<PluginInputsForm
fields={[{ name: 'reference', label: 'Reference file', type: 'file' }]}
values={{}}
onChange={onChange}
onValidityChange={onValidityChange}
/>,
);
const input = screen.getByLabelText(/Reference file/) as HTMLInputElement;
const file = new File(['brief'], 'brief.txt', { type: 'text/plain' });
fireEvent.change(input, { target: { files: [file] } });
expect(onChange).toHaveBeenCalledWith({
reference: expect.objectContaining({
name: 'brief.txt',
size: 5,
type: 'text/plain',
}),
});
expect(screen.getByText('brief.txt')).toBeTruthy();
});
});

View file

@ -20,6 +20,7 @@ interface MakeArgs {
source?: string;
sourceKind?: InstalledPluginRecord['sourceKind'];
marketplaceId?: string;
marketplaceEntryName?: string;
authorUrl?: string;
homepage?: string;
}
@ -32,6 +33,7 @@ function make(args: MakeArgs): InstalledPluginRecord {
sourceKind: args.sourceKind ?? 'bundled',
source: args.source ?? `plugins/${args.id}`,
sourceMarketplaceId: args.marketplaceId,
sourceMarketplaceEntryName: args.marketplaceEntryName,
trust: 'bundled',
capabilitiesGranted: [],
manifest: {
@ -107,19 +109,20 @@ describe('PluginShareMenu', () => {
});
}
it('copies an install command for marketplace plugins using the marketplace id', async () => {
it('copies an install command for marketplace plugins using the registry entry name', async () => {
renderMenu(
make({
id: 'mp-plugin',
sourceKind: 'marketplace',
source: 'https://registry.example/plugins/mp-plugin',
marketplaceId: '@official/mp-plugin',
sourceKind: 'github',
source: 'github:open-design/plugins/mp-plugin',
marketplaceId: 'official',
marketplaceEntryName: 'open-design/mp-plugin',
}),
);
openPopover();
clickItem('Copy install command');
await Promise.resolve();
expect(writes).toContain('od plugin install @official/mp-plugin');
expect(writes).toContain('od plugin install open-design/mp-plugin');
});
it('copies the github source string for github-installed plugins', async () => {

View file

@ -183,14 +183,15 @@ describe('PluginsView', () => {
fireEvent.click(await screen.findByTestId('plugins-create-button'));
expect(onCreatePlugin).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('dialog', { name: 'Create or import a plugin' })).toBeNull();
expect(screen.queryByRole('dialog', { name: 'Import a plugin' })).toBeNull();
});
it('shows installed plugins and available registry entries', async () => {
render(<PluginsView />);
await waitFor(() => expect(screen.getAllByText('Official Plugin').length).toBeGreaterThan(0));
expect(await screen.findByText('Installed plugins')).toBeTruthy();
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');
@ -202,20 +203,35 @@ describe('PluginsView', () => {
expect(screen.getByText('Example Catalog')).toBeTruthy();
});
it('hands installed plugin Use actions to the host shell', async () => {
const onUsePlugin = vi.fn();
render(<PluginsView onUsePlugin={onUsePlugin} />);
fireEvent.click(await screen.findByTestId('plugins-home-use-user-plugin'));
expect(onUsePlugin).toHaveBeenCalledWith(expect.objectContaining({
id: 'user-plugin',
title: 'User Plugin',
}));
expect(mockedApplyPlugin).not.toHaveBeenCalled();
});
it('installs from a supported source string', async () => {
render(<PluginsView />);
expect(screen.queryByTestId('plugins-tab-import')).toBeNull();
fireEvent.click(await screen.findByTestId('plugins-import-button'));
expect(screen.getByRole('dialog', { name: 'Create or import a plugin' })).toBeTruthy();
expect(screen.getByRole('dialog', { name: 'Import a plugin' })).toBeTruthy();
expect(screen.queryByText('Create from template')).toBeNull();
const source = 'github:nexu-io/open-design@garnet-hemisphere/plugins/community/registry-starter';
fireEvent.change(screen.getByLabelText('GitHub, archive, or marketplace source'), {
target: { value: 'github:owner/repo/plugins/my-plugin' },
target: { value: source },
});
fireEvent.click(screen.getByRole('button', { name: 'Import' }));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith(
'github:owner/repo/plugins/my-plugin',
source,
),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
@ -235,18 +251,62 @@ describe('PluginsView', () => {
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
});
it('treats bundled official registry entries as installed and ready to use', async () => {
const official = makePlugin('official-plugin', 'bundled', 'bundled', 'Official Plugin');
official.sourceMarketplaceId = 'official';
official.sourceMarketplaceEntryName = 'open-design/official-plugin';
official.sourceMarketplaceEntryVersion = '1.0.0';
official.marketplaceTrust = 'official';
mockedListPlugins.mockResolvedValue([official]);
mockedListMarketplaces.mockResolvedValue([
{
id: 'official',
url: 'https://open-design.ai/marketplace/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Open Design Official',
version: '0.1.0',
plugins: [{
name: 'open-design/official-plugin',
title: 'Official Plugin',
source: 'github:nexu-io/open-design@main/plugins/_official/scenarios/official-plugin',
version: '1.0.0',
description: 'Bundled official starter.',
tags: ['official'],
}],
},
},
]);
render(<PluginsView />);
fireEvent.click(await screen.findByTestId('plugins-tab-available'));
const card = (await screen.findByText('Official Plugin')).closest('article');
expect(card).not.toBeNull();
expect(within(card!).getByText('Open Design Official')).toBeTruthy();
expect(within(card!).queryByRole('button', { name: 'Install' })).toBeNull();
fireEvent.click(within(card!).getByRole('button', { name: 'Use' }));
await waitFor(() =>
expect(mockedApplyPlugin).toHaveBeenCalledWith('official-plugin', { locale: 'en' }),
);
});
it('manages registry sources from the Sources tab', async () => {
render(<PluginsView />);
const sourceUrl =
'https://raw.githubusercontent.com/nexu-io/open-design/garnet-hemisphere/plugins/registry/community/open-design-marketplace.json';
fireEvent.click(await screen.findByTestId('plugins-tab-sources'));
fireEvent.change(screen.getByLabelText('Source URL'), {
target: { value: 'https://example.com/next.json' },
target: { value: sourceUrl },
});
fireEvent.click(screen.getByRole('button', { name: 'Add source' }));
await waitFor(() =>
expect(mockedAddMarketplace).toHaveBeenCalledWith({
url: 'https://example.com/next.json',
url: sourceUrl,
trust: 'restricted',
}),
);

View file

@ -80,6 +80,24 @@ const sample: InstalledPluginRecord[] = [
];
describe('PluginsHomeSection (category bar)', () => {
it('frames the home shelf as official starters and can jump to registry', () => {
const onBrowseRegistry = vi.fn();
render(
<PluginsHomeSection
plugins={sample}
loading={false}
activePluginId={null}
pendingApplyId={null}
onUse={() => {}}
onOpenDetails={() => {}}
onBrowseRegistry={onBrowseRegistry}
/>,
);
expect(screen.getByText('Official starters')).toBeTruthy();
fireEvent.click(screen.getByTestId('plugins-home-browse-registry'));
expect(onBrowseRegistry).toHaveBeenCalledTimes(1);
});
it('renders a single category row with All + curated buckets', () => {
render(
<PluginsHomeSection

View file

@ -27,21 +27,22 @@ References (shape, not API):
- [ ] **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
- [x] **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,
- [x] **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
`bundled|trusted|restricted` — P0 unifies these and normalizes legacy
`untrusted` rows to `restricted`.)
- [x] **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
- [x] **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.
@ -96,7 +97,8 @@ 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`.
immediately click `Use`. Bundled official plugins are the preinstalled cache
of the `official` registry source, not a separate distribution model.
- **Plugins / Installed** is the full local runnable inventory: bundled
official plugins, user-created plugins, direct GitHub/URL/local installs, and
marketplace-installed plugins.
@ -107,8 +109,8 @@ Product surface semantics:
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.
- **open-design.ai/plugins** is the public renderer of the official and
community registry sources, not a separate source of truth.
Agent consumption boundary:
@ -117,7 +119,12 @@ User-added registry
Sources -> Available -> Install -> Installed -> agent context/runtime
Packaged official plugins
bundled with OD -> Installed/bundled -> Home Official starters -> agent
official registry -> bundled preinstall cache -> Installed/bundled
-> Home Official starters -> agent
Default community registry
community source -> Available by default -> user installs intentionally
-> ~/.open-design/plugins/<plugin-id> -> Installed -> agent
```
`Available` is not directly consumable by the agent. It is a supply pool. The
@ -126,6 +133,13 @@ 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.
User-created and user-installed plugins are persisted under the user plugin
root (`~/.open-design/plugins/<plugin-id>` by default). Daemon startup reloads
installed records from SQLite and resolves those user-state plugin folders; a
packaged runtime upgrade should not overwrite them. Bundled official plugins
stay inside the runtime image and are re-registered on boot as official-source
preinstalls.
Authoring and publishing mirror the consumption loop:
```text
@ -257,8 +271,9 @@ Fallback archives require integrity hashes.
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`.
it to `main`. GitHub Pages / CDN serves it as the fetchable marketplace
JSON, while `https://open-design.ai/plugins/` renders the public browser
and detail pages over that same source data.
**Yanking** uses the same PR shape with a `yanked: true, reason: "..."` patch
to the version JSON. Daemons treat yanked versions as unresolvable but
@ -305,10 +320,12 @@ swap symlink, rollback on failure).
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.
- **Official site (open-design.ai/plugins)** — static, SSG against
repo-owned `plugins/registry/*/open-design-marketplace.json` sources. Browse,
search, copy install command, render plugin details, preview asset,
capability & permission summary, version history, publisher links, and
canonical SEO pages. `open-design.ai/marketplace` can be kept as an alias
once routes are finalized.
- **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
@ -337,6 +354,61 @@ 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.
### 1.8 Current implementation slice (2026-05-13 / 2026-05-14)
This repo now has the first registry closure in place:
- Contracts and JSON schema use `official / trusted / restricted` marketplace
trust, and marketplace entries can carry `versions`, `dist`, `distTags`,
`integrity`, `manifestDigest`, `publisher`, `homepage`, `license`,
`capabilitiesSummary`, `deprecated`, and yanking metadata while staying
passthrough-friendly.
- Daemon install and upgrade flows preserve marketplace provenance:
`sourceMarketplaceId`, entry name/version, marketplace trust, resolved
source/ref, manifest digest, and archive integrity. Snapshot records carry
the same audit trail for agent/runtime replay.
- The packaged daemon seeds built-in `official` and `community` registry
sources from `plugins/registry/*/open-design-marketplace.json`. `official` is
verified and can also hydrate bundled preinstalls; `community` is restricted
by default and feeds Available entries for user-initiated installs.
- Bundled official plugins now carry `sourceMarketplaceId=official` and
`sourceMarketplaceEntryName=open-design/<plugin-id>`, so they are modeled as
preinstalled official registry entries while keeping offline first-run bytes
in the runtime image.
- `od plugin login` and `od plugin whoami` now delegate to `gh`, and
registry publishing now has three paths: `--to open-design` produces the
human review target/link, `--to marketplace-json --catalog <path>` upserts a
self-hosted static catalog entry, and `GithubRegistryBackend.publish/yank`
produces deterministic PR mutation payloads for a real GitHub mutator.
- `packages/registry-protocol` defines the backend interface and schemas;
daemon now has static, GitHub, and SQLite database-backed implementations
sharing the same list/search/resolve/publish/yank/doctor contract.
- Version resolution supports exact versions, dist-tags, `^`/`~` ranges, and
yanking behavior. Archive downloads verify/store SHA-256 integrity, and a
lockfile helper records resolved marketplace provenance for reproducible
installs.
- `od marketplace plugins`, `od marketplace doctor`, `od marketplace login`,
versioned `od plugin install`, policy-aware `od plugin upgrade`, marketplace
`od plugin info`, and `od plugin yank` are implemented as headless surfaces
with JSON output where automation needs it.
- Home now presents official plugins as `Official starters` with a
`Browse registry` path into `/plugins`; `/plugins` remains the registry
console (`Installed / Available / Sources / Team`).
- `apps/landing-page` now exposes the public SEO renderer at `/plugins/` plus
static per-plugin detail pages. It reads `plugins/registry/official`,
`plugins/registry/community`, and bundled official manifests at build time,
so open-design.ai can show the ecosystem without calling daemon APIs.
- The `Create plugin` product prompt is agent-assisted and explicitly drives
scaffold/validate/local install/pack/login/whoami/publish expectations.
- Registry evaluation cases now live in
[`docs/testing/plugin-registry-eval-cases.md`](../testing/plugin-registry-eval-cases.md).
The first covered set locks raw `open-design-marketplace.json` source input,
populated official seed loading, default community seed loading,
provenance/trust inheritance, bundled official `Use` behavior in Available,
direct GitHub imports, the Create/Publish agent handoff surfaces, version
resolution/yanking, backend parity, registry doctor, archive integrity,
lockfiles, and static marketplace-json publishing.
---
## 2. Phased plan
@ -346,22 +418,22 @@ provenance-aware `--from <marketplace-id>` install semantics.
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`,
- [x] **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`
- [x] **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`,
- [x] **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`
- [x] **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
- [x] **P0.5 Registry protocol package.** New
`packages/registry-protocol/` with the `RegistryBackend` interface,
shared zod schemas, and pure tests. No runtime deps.
@ -370,7 +442,7 @@ and a versioned marketplace entry.
Goal: every registry action you'd want from the website works on the CLI
first, headless, JSON-emitting.
- [ ] **P1.1 New CLI subcommands.**
- [x] **P1.1 New CLI subcommands.**
- `od marketplace plugins <id> [--json]`
- `od marketplace search <query> [--json]`
- `od marketplace doctor [<id>]`
@ -380,24 +452,28 @@ first, headless, JSON-emitting.
- `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`
These now cover marketplace plugins/search/doctor/login, versioned install,
policy-aware upgrade, marketplace info, yanking, and gh-backed login/whoami.
- [x] **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
raw HTTPS/static reads and a narrow mutation client for PR creation, keeping
`gh`/GitHub auth outside daemon persistence.
- [x] **P1.3 Publish orchestrator, first mutation-capable slice.**
`GithubRegistryBackend.publish/yank` builds deterministic registry files and
PR payloads; `od plugin publish --to marketplace-json --catalog <path>`
covers self-hosted static catalogs. The release-upload/fork/branch executor
remains an adapter on top of the tested mutation contract.
- [x] **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>`
- [x] **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
- [x] **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/`:
- [x] **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.
@ -410,17 +486,19 @@ first, headless, JSON-emitting.
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`
The bare-name install path now persists marketplace provenance; explicit
`--from <marketplace-id>` remains follow-up work.
- [x] **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
- [x] **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`.
PR through `od plugin publish`. Current slice upgrades the product prompt,
CLI wrapper, marketplace-json self-host publish, and tested GitHub PR
mutation payload contract.
- [ ] **P2.5 Plugin detail drawer.** Provenance line, permissions, capability
summary, version dropdown, install command (copy-to-clipboard, matches
`od plugin install <name>@<version>`).
@ -433,41 +511,48 @@ first, headless, JSON-emitting.
### P3 — Official website + ecosystem
- [ ] **P3.1 Stand up `open-design/plugin-registry` repo.** Schema, validation
- [x] **P3.1 Stand up `open-design/plugin-registry` repo shape.** 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
the bundled plugins currently shipped in `plugins/_official/`. The local repo
now carries the source shape and generated registry inputs; creating the
external GitHub repo is an operational launch step, not a code blocker.
- [x] **P3.2 Static site renderer.** `apps/landing-page` now
statically generates `open-design.ai/plugins` and per-plugin detail routes
from `plugins/registry/*/open-design-marketplace.json` plus bundled official
manifests, with SEO metadata, search JSON, and `od://` detail links.
- [x] **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
- [x] **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
- [x] **P3.5 `od plugin publish --to marketplace-json`.** Lets third-party
catalog owners accept submissions from their own users using the same CLI by
writing/upserting their own static `open-design-marketplace.json`.
- [x] **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.
- [x] **P3.7 Publisher verification hooks.** Lightweight GitHub-org publisher
metadata is generated for marketplace-json entries and protocol schemas now
support verified publishers/signatures. Heavier sigstore/cosign enforcement
remains deferred — open question §4.
### P4 — Future / non-blocking
- [ ] **P4.1 DB-backed RegistryBackend.** Same interface, SQLite or Postgres.
- [x] **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.
- [x] **P4.2 Search index.** Static `open-design.ai/plugins/search.json`
exposes the website search index; CLI still works against
`marketplace.json` directly. Typesense/Meilisearch can replace the static
file later without changing registry semantics.
- [x] **P4.3 Web-of-trust badges data hooks.** Protocol schemas include
optional metrics for downloads, installs, stars, and activity timestamps.
Real collection/display stays opt-in per spec privacy stance.
- [x] **P4.4 Marketplace signing data hooks.** Protocol schemas include
optional signature records for GitHub OIDC/cosign/minisign/custom signing.
Client-side enforcement is intentionally deferred until the registry service
starts emitting signed catalogs.
---

View file

@ -412,7 +412,7 @@ When a plugin has no `open-design.json`, but its `SKILL.md` already contains the
| `od.preview` | `od.preview` | Only `type` and `entry` enter the v1 manifest; `reload` is kept as adapter metadata and does not enter the public contract |
| `od.design_system.requires` | `od.context.designSystem` | `true` means use the active project design system at run time; `sections` is preserved in resolved context as a prompt-pruning hint |
| `od.craft.requires` | `od.context.craft` | Slug array maps directly |
| `od.inputs` | `od.inputs` | `string``string`, `integer``number`, `enum``select`, `values``options`; `min` / `max` are preserved as future metadata, and v1 UI may ignore but must not discard them |
| `od.inputs` | `od.inputs` | `string``string`, `integer``number`, `enum``select`, `upload``file`, `values``options`; `min` / `max` are preserved as future metadata, and v1 UI may ignore but must not discard them |
| `od.parameters` | adapter metadata | v1 plugin apply does not render live sliders; fields are preserved for Phase 4 and do not enter `ApplyResult.inputs` |
| `od.outputs` | `projectMetadata` hints | Used for artifact bookkeeping and preview defaults, not surfaced as user-editable inputs |
| `od.capabilities_required` | `od.capabilities` | Map only capabilities that can be expressed; unknown capabilities are kept in `compatWarnings[]`, and `od plugin doctor` must surface them |
@ -476,7 +476,8 @@ GitHub install path uses `https://codeload.github.com/owner/repo/tar.gz/<ref>`,
The plugin system exposes two apply surfaces; both call the same daemon endpoint and receive the same `ApplyResult`. In v1, **apply is pure by default**: it reads the manifest, templates the query, and returns context chips, inputs, asset refs, and MCP specs; it does not write the project cwd, copy assets, write `.mcp.json`, or start any process. Side effects happen later in `POST /api/projects` or `POST /api/runs`, after the same trust/capability gate runs.
- **Detail-page apply** (deep): user navigates to `/marketplace/:id`, reviews the preview and capability checklist, clicks "Use this plugin". The composer is hydrated and the user lands back on Home or in the project. If the user cancels before sending, the daemon has no staged files to clean up.
- **Inline apply** (shallow, the primary product surface): the input box on Home and the input box inside an existing project (ChatComposer) both render an **inline plugins rail** directly below them. Clicking a plugin card in the rail applies the plugin **in place** — no navigation, no context loss. The brief input prefills, the chip strip above the input populates, and the plugin's `od.inputs` form blanks render between the input and the Send button. The user fills a few blanks, tweaks the brief, hits Send. This is the natural-language, fill-in-the-blank interaction that drives both project creation and per-project tasks.
- **Inline apply** (shallow, the primary product surface): the input box on Home and the input box inside an existing project (ChatComposer) both render an **inline plugins rail** directly below them. Clicking a plugin card's explicit **Use** action applies the plugin **in place** — no navigation, no context loss. The brief input prefills, the chip strip above the input populates, and the plugin's `od.inputs` form blanks render between the input and the Send button. The user fills a few blanks, tweaks the brief, hits Send. This is the natural-language, fill-in-the-blank interaction that drives both project creation and per-project tasks. If the brief already contains user text, the UI must confirm before replacing it.
- **@ plugin mentions** are additive context, not apply. Selecting a plugin from the `@` picker removes the mention token, adds a removable plugin context chip, and never hydrates `od.useCase.query`. Users can select multiple plugins this way and combine them with skills or MCP context; hovering a plugin result should expose plugin details/parameters so the user can decide whether to explicitly Use it.
The two surfaces share the same primitives (`InlinePluginsRail`, `ContextChipStrip`, `PluginInputsForm`) and the same `applyPlugin()` state helper. The only difference is the terminal endpoint: Home calls `POST /api/projects` to create a new project; ChatComposer calls `POST /api/runs` to create a new task in the active project.
@ -628,11 +629,12 @@ Only the daemon writes `AppliedPluginSnapshot`; CLI/UI clients are read-only. Pl
### 8.3 Inline `od.inputs` form
When the applied plugin declares `od.inputs`, the composer renders a `PluginInputsForm` between the brief textarea and the Send button. Behavior:
When the applied plugin declares `od.inputs`, the composer renders a highlighted `PluginInputsForm` inside the brief input surface. Behavior:
- Required fields gate Send (the button is disabled with a tooltip listing missing fields).
- Plugins with required inputs may be selected before all answers are present. The client should render the form from manifest-declared `od.inputs`, then apply/pin the snapshot once required values are available.
- As the user types, `{{var}}` placeholders inside `useCase.query` and inside any string-valued `context` entry re-render live, so the user sees the final brief and final chip labels before sending.
- The form is compact by default — short fields render inline like a search bar; long-text fields collapse to a "Add details" expander.
- The form is compact by default — short fields render inline like fill-in-the-blank slots; `select`, `boolean`, and `file` fields use native controls, and long text fields expand within the same input surface.
- On Send, input values are sent alongside the run request; the daemon also passes them into the prompt under a small `## Plugin inputs` block so the agent has the literal user-supplied values, not just the post-template brief.
- Inputs persist in component state until the user clears the chip strip — re-applying the same plugin on the same composer pre-fills last-used values.

View file

@ -0,0 +1,66 @@
# Publishing An Open Design Plugin
Open Design registry publishing is GitHub-backed in v1. The CLI remains the
canonical workflow; the product UI and agent flows wrap these commands.
## 1. Scaffold
```bash
od plugin scaffold --id vendor/plugin-name --title "Plugin name" --out ./plugins/community
```
Public registry IDs must use `vendor/plugin-name`. The generated
`open-design.json` must include `plugin.repo`, pointing at the canonical source
repository or subdirectory.
## 2. Validate And Pack
```bash
od plugin validate ./plugins/community/plugin-name
od plugin pack ./plugins/community/plugin-name --out ./dist
```
The registry accepts anything that validates and packs. The source repository
does not need a special layout beyond `SKILL.md` plus `open-design.json`.
## 3. Authenticate
```bash
od plugin login
od plugin whoami --json
```
These commands wrap GitHub CLI. Tokens stay in `gh`; Open Design does not store
GitHub credentials.
## 4. Publish
```bash
od plugin publish vendor/plugin-name --to open-design --repo https://github.com/vendor/plugin-name
```
v1 opens the GitHub registry review flow. The publish payload includes the
plugin ID, version, repo, capability summary, package digest, and registry entry
path. After merge, CI regenerates `open-design-marketplace.json`.
## 5. Install From The Registry
```bash
od marketplace refresh official
od plugin install vendor/plugin-name
od plugin info vendor/plugin-name --json
```
Installs preserve marketplace provenance, resolved source, manifest digest, and
archive integrity. `official` and `trusted` sources install as trusted;
`restricted` sources stay restricted until the user grants more trust.
## 6. Yank A Version
```bash
od plugin yank vendor/plugin-name@1.0.0 --reason "Security issue"
```
Yanking never deletes metadata or bytes. New installs refuse yanked versions;
existing exact lockfile replays can still warn and proceed if the archive
remains reachable and integrity matches.

View file

@ -0,0 +1,64 @@
# 发布 Open Design 插件
Open Design registry v1 复用 GitHub 作为后端。CLI 是 canonical workflow
产品 UI 和 agent 创建流程只是包装这些命令。
## 1. 创建
```bash
od plugin scaffold --id vendor/plugin-name --title "Plugin name" --out ./plugins/community
```
公开 registry ID 必须是 `vendor/plugin-name`。生成的 `open-design.json`
需要包含 `plugin.repo`,指向插件的源码仓库或源码子目录。
## 2. 校验和打包
```bash
od plugin validate ./plugins/community/plugin-name
od plugin pack ./plugins/community/plugin-name --out ./dist
```
registry 接受任何能通过 validate 和 pack 的插件。源码仓库不需要特殊结构,
只需要 `SKILL.md``open-design.json`
## 3. 登录
```bash
od plugin login
od plugin whoami --json
```
这两个命令包装 GitHub CLI。token 留在 `gh`Open Design 不保存 GitHub
凭据。
## 4. 发布
```bash
od plugin publish vendor/plugin-name --to open-design --repo https://github.com/vendor/plugin-name
```
v1 会打开 GitHub registry review flow。发布 payload 包含插件 ID、版本、
源码仓库、能力摘要、包 digest 和 registry entry path。合并之后CI 重新生成
`open-design-marketplace.json`
## 5. 从 registry 安装
```bash
od marketplace refresh official
od plugin install vendor/plugin-name
od plugin info vendor/plugin-name --json
```
安装记录会保留 marketplace provenance、resolved source、manifest digest 和
archive integrity。`official` / `trusted` 来源默认安装为 trusted`restricted`
来源仍然保持 restricted直到用户主动授权。
## 6. Yank 版本
```bash
od plugin yank vendor/plugin-name@1.0.0 --reason "Security issue"
```
Yank 不删除元数据和包。新安装会拒绝 yanked version已经存在的精确 lockfile
重放可以在 integrity 匹配且 archive 仍可访问时带警告继续。

View file

@ -37,6 +37,80 @@
"source": { "type": "string", "minLength": 1 },
"version": { "type": "string", "minLength": 1 },
"ref": { "type": "string" },
"dist": {
"type": "object",
"properties": {
"type": { "type": "string" },
"archive": { "type": "string" },
"integrity": { "type": "string" },
"manifestDigest": { "type": "string" }
},
"additionalProperties": true
},
"versions": {
"type": "array",
"items": {
"type": "object",
"required": ["version"],
"properties": {
"version": { "type": "string", "minLength": 1 },
"source": { "type": "string", "minLength": 1 },
"ref": { "type": "string" },
"dist": {
"type": "object",
"properties": {
"type": { "type": "string" },
"archive": { "type": "string" },
"integrity": { "type": "string" },
"manifestDigest": { "type": "string" }
},
"additionalProperties": true
},
"integrity": { "type": "string" },
"manifestDigest": { "type": "string" },
"deprecated": {
"oneOf": [
{ "type": "boolean" },
{ "type": "string" }
]
},
"yanked": { "type": "boolean" },
"yankedAt": { "type": "string" },
"yankReason": { "type": "string" }
},
"additionalProperties": true
}
},
"distTags": {
"type": "object",
"additionalProperties": { "type": "string" }
},
"integrity": { "type": "string" },
"manifestDigest": { "type": "string" },
"publisher": {
"type": "object",
"properties": {
"id": { "type": "string" },
"github": { "type": "string" },
"url": { "type": "string" }
},
"additionalProperties": true
},
"homepage": { "type": "string" },
"license": { "type": "string" },
"capabilitiesSummary": {
"type": "array",
"items": { "type": "string" }
},
"deprecated": {
"oneOf": [
{ "type": "boolean" },
{ "type": "string" }
]
},
"yanked": { "type": "boolean" },
"yankedAt": { "type": "string" },
"yankReason": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"title": { "type": "string" },
"description": { "type": "string" },

View file

@ -229,7 +229,7 @@
"properties": {
"name": { "type": "string", "minLength": 1 },
"label": { "type": "string" },
"type": { "type": "string", "enum": ["string", "text", "select", "number", "boolean"] },
"type": { "type": "string", "enum": ["string", "text", "select", "number", "boolean", "file"] },
"required": { "type": "boolean" },
"options": { "type": "array", "items": { "type": "string" } },
"placeholder": { "type": "string" },

View file

@ -0,0 +1,64 @@
# Self-hosting An Open Design Registry
An Open Design registry is a source of `open-design-marketplace.json` plus the
review process that produces it. In v1 this can be a static GitHub repository,
GitHub Enterprise, S3/R2, or any HTTPS host.
## Static Catalog Shape
```text
plugins/registry/
official/open-design-marketplace.json
community/open-design-marketplace.json
plugins/community/<vendor>/<plugin-name>/
SKILL.md
open-design.json
```
The machine-readable URL is the raw JSON file:
```bash
od marketplace add https://example.com/open-design-marketplace.json --trust restricted
od marketplace refresh <id>
od marketplace search "deck" --json
```
Do not add a GitHub tree page. The daemon validates the response as JSON and
rejects HTML.
## Private GitHub Or GitHub Enterprise
```bash
od marketplace login https://github.example.com/org/plugin-registry
od marketplace add https://raw.github.example.com/org/plugin-registry/main/open-design-marketplace.json --trust trusted
```
Authentication is delegated to `gh auth login --hostname <host>`. Tokens stay
inside GitHub CLI.
## Doctor
```bash
od marketplace doctor <id> --strict --json
```
Doctor checks stable `vendor/plugin-name` IDs, source/archive presence,
archive integrity, yanking reasons, dist-tag consistency, publisher identity,
license, and capability summaries.
## Database Backend Path
The runtime code talks to `RegistryBackend`. A static JSON registry, GitHub PR
registry, and database registry expose the same list/search/resolve/publish/yank
contract. A commercial deployment can replace the static backend with a managed
database for:
- private catalogs
- organization allowlists
- approval workflows
- SSO-backed publisher identity
- audit logs
- entitlements and paid distribution
The CLI vocabulary stays the same: `od marketplace add/search/doctor`,
`od plugin install/upgrade/publish/yank`.

View file

@ -7,6 +7,7 @@
- 优先记录已经存在于 `e2e/` 下的自动化覆盖;当某个用户流主要由 `apps/web` 组件测试保护时,也会一并注明。
- 以用户视角描述场景,不展开实现细节。
- 新增测试文件或新增重要场景时,同步更新对应模块文档。
- 插件系统总验收维护在 [`../plugin-system-test-suite.md`](../plugin-system-test-suite.md)Registry / CLI / daemon 跨层用例另见 [`../plugin-registry-eval-cases.md`](../plugin-registry-eval-cases.md)。
## 模块索引

View file

@ -0,0 +1,51 @@
# Plugin Registry 评测集用例
这份用例集把 registry 产品心智转成可回归的断言:`Sources` 只接收
`open-design-marketplace.json``Available` 是供应池,`Installed` 才是 agent
可消费集合official/community/self-hosted 都通过同一套 registry source 模型进入系统。
插件系统整体进度、运行顺序和发布验收总表见
[`plugin-system-test-suite.md`](./plugin-system-test-suite.md)。本文只保留
registry / distribution / website / multi-source 的细分用例。
## 已自动化
| ID | 场景 | 核心断言 | 覆盖文件 |
| --- | --- | --- | --- |
| REG-001 | Sources 添加的是 raw `open-design-marketplace.json`,不是 GitHub tree 页面 | GitHub tree HTML 会被 marketplace parser 拒绝并返回 422 | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-002 | 默认 official registry seed 是真实 catalog不是空数组 | `plugins/registry/official/open-design-marketplace.json` 包含 bundled official entriestrust 为 `official`,且 `open-design/build-test` 可 resolve | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-003 | 默认 community registry seed 可被 daemon 当作 restricted source 加载 | `plugins/registry/community/open-design-marketplace.json` 可 seed`community/registry-starter` 可 resolvetrust 为 `restricted` | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-004 | checked-in registry entry 指向真实可打包插件源码 | `community/registry-starter` 的 source 指向 `plugins/community/registry-starter`,源码 `open-design.json``plugin.repo` | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-005 | marketplace install 会保留 provenance 并继承 trust | installed record 写入 `sourceMarketplaceId`、entry name/version、resolved source/ref、digest/integrityofficial/trusted source 默认 trusted | `apps/daemon/tests/plugins-installer.test.ts` |
| REG-006 | restricted marketplace install 不会被自动提权 | restricted source 安装出的 plugin 仍是 `restricted` | `apps/daemon/tests/plugins-installer.test.ts` |
| REG-007 | 直接 GitHub source import 与 registry source 是两条入口 | Import dialog 会把 `github:nexu-io/open-design@.../plugins/community/registry-starter` 原样交给 install API | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-008 | Available 里的 bundled official entry 已安装时显示 `Use`,不是 `Install` | registry entry `open-design/official-plugin` 能匹配 installed bundled record并调用 `applyPlugin` | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-009 | Sources tab 支持填入 raw GitHub `open-design-marketplace.json` URL | UI 调用 `addPluginMarketplace({ url, trust: "restricted" })` | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-010 | Create plugin 是 agent-assisted authoring 入口 | `Create plugin` 不打开旧 import modal而是触发 `onCreatePlugin` agent 流程 | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-011 | 用户插件可通过 publish/share action 进入 GitHub registry 工作流 | Publish/Contribute action 会确认后创建对应 agent task携带 source plugin id 和 action id | `apps/web/tests/components/PluginsView.test.tsx` |
| REG-012 | version range / dist-tag / yank resolution | `vendor/plugin@1.0.0`、`@latest`、`@^1.0.0` 可解析yanked beta 不参与新解析 | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| REG-013 | archive integrity fail closed | HTTPS/GitHub tarball 下载会计算 `sha256:`entry integrity 不匹配时拒绝解包,匹配/缺省时写入 installed record | `apps/daemon/tests/plugins-installer-archive.test.ts` |
| REG-014 | registry backend parity | static/GitHub/DB backend 共享 list/search/resolve/publish contractGitHub publish 产出稳定 PR mutation paths | `apps/daemon/tests/registry-backends.test.ts` |
| REG-015 | install lockfile | installed plugin 可生成稳定 `.od/od-plugin-lock.json` entry包含 marketplace id、resolved ref、digest、integrity | `apps/daemon/tests/plugins-lockfile.test.ts`, `apps/daemon/tests/plugins-installer.test.ts` |
| REG-016 | marketplace doctor | invalid name、missing source、missing capability/license、yank reason 等会被 doctor 报告,并支持 strict warning-as-error | `apps/daemon/tests/plugins-marketplace-doctor.test.ts` |
| REG-017 | static marketplace-json publish | `od plugin publish --to marketplace-json` 的纯 upsert 逻辑强制 `vendor/plugin-name`,从 GitHub URL 推导 reproducible source并稳定更新 catalog | `apps/daemon/tests/plugins-publish.test.ts` |
| REG-018 | public plugin SEO/search renderer | `/plugins/search.json` 和 per-plugin detail pages 可静态构建,包含 official/community registry entry | `apps/landing-page` `typecheck` + `build` |
| REG-019 | registry protocol future hooks | `RegistryBackend` 纯接口要求 vendor/plugin identity并接受 metrics/signatures为 DB/search/trust hardening 预留 | `packages/registry-protocol/tests/backend.test.ts` |
## 自动化候选
| ID | 场景 | 建议补法 |
| --- | --- | --- |
| REG-C01 | `od marketplace add/search/refresh/remove/trust` CLI 全链路 | CLI harness + fake fetcher断言 JSON 输出、exit code、SQLite source row |
| REG-C02 | `od plugin login/whoami` 只复用 `gh`,不保存 GitHub token | fake `GhClient` 或 fake `gh` bin断言 stdout 和无 token 持久化 |
| REG-C03 | 完整 `gh repo fork` / `gh pr create` 外部流程 | fake `gh` bin + temp registry repo断言真实 branch/commit/PR 命令序列 |
| REG-C04 | `open-design-marketplace.json` 生成器 | 输入多个 `plugins/community/**/open-design.json`输出排序稳定、schema 通过、source/digest 完整 |
| REG-C05 | lockfile replay route-level behavior | 启动 daemon先安装 `vendor/plugin@1.0.0` 写 lock再默认安装 `vendor/plugin`,断言仍解析 lock 里的 exact version |
| REG-C06 | enterprise database backend HTTP/API parity | 同一组 CLI/UI 行为同时跑 static/GitHub 和 DB backend而不只是 backend unit parity |
## 手工验收保留
| ID | 场景 | 原因 |
| --- | --- | --- |
| REG-M01 | open-design.ai marketplace 页面视觉、SEO、插件详情叙事 | 强依赖品牌表达和真实内容质量,适合人工验收 |
| REG-M02 | 第三方真实自托管 registry 接入体验 | 涉及外部 repo、GitHub 权限、网络和组织流程,适合作为发布前 smoke |

View file

@ -0,0 +1,313 @@
# Plugin System 测试集与验收指南
状态日期2026-05-14
这份文档覆盖 `docs/plugins-spec.md`、`docs/plans/plugin-registry.md`、`docs/plans/plugins-implementation.md`
本期插件系统需求。目标是把需求进度、自动化测试、手工验收和推荐执行顺序放在同一个地方,方便发布前按清单验证。
## 1. 当前进度摘要
| 模块 | 进度 | 证据 | 发布判断 |
| --- | --- | --- | --- |
| Plugin manifest / contracts | 基本完成 | `packages/contracts/src/plugins/*`、`packages/contracts/tests/plugins-manifest.test.ts` | 进入回归维护 |
| Plugin runtime parser / merge / digest | 基本完成 | `packages/plugin-runtime/src/*`、`packages/plugin-runtime/tests/*` | 进入回归维护 |
| Daemon install / apply / snapshot | 完成 | `apps/daemon/src/plugins/{installer,apply,snapshots,resolve-snapshot}.ts`、`apps/daemon/tests/plugins-dod-e2e.test.ts` | v1 主路径可验收 |
| Pipeline / GenUI / devloop | 完成主路径 | `apps/daemon/src/plugins/{pipeline,pipeline-runner,until}.ts`、`apps/daemon/src/genui/*` | 需要继续跑事件流回归 |
| First-party atoms and scenarios | Phase 6/7/8 已落地 | `apps/daemon/src/plugins/atoms/*`、`plugins/_official/scenarios/*`、对应 `plugins-*-e2e.test.ts` | 需要按场景抽样验收 |
| Headless CLI loop | 主路径完成 | `od plugin install/run`、`od project create`、`od run start/watch`、`apps/daemon/tests/plugins-headless-run.test.ts` | v1 必测 |
| Federated registry | P0/P1/P3/P4 大多完成 | `packages/registry-protocol`、`apps/daemon/src/registry/*`、`apps/daemon/tests/registry-backends.test.ts` | DoD 仍有开放项 |
| Web Plugins UI | Installed / Available / Sources 可用Team 未完成 | `apps/web/src/components/PluginsView.tsx`、`apps/web/tests/components/PluginsView.test.tsx` | 需要 UI 手工验收 |
| Plugin detail surface | 已有详情 modal、provenance、capabilities、share menu | `PluginDetailsModal.tsx`、`plugin-details/*` | P2.5 的 version dropdown 仍需补 |
| Team / private marketplace UI | 未完成 | `TeamPanel()` 仍是 coming soon | P2.6 未达成 |
| Trust badge consistency | 部分完成 | cards/detail/source tab 有 `official/trusted/restricted` 文案 | P2.7 需要视觉和文案统一验收 |
| Registry v1 DoD | 未完全关闭 | `docs/plans/plugin-registry.md` §4 仍是 `[ ]` | 不应标为 registry v1 fully done |
### 当前开放项
| ID | 开放项 | 测试策略 |
| --- | --- | --- |
| GAP-001 | `plugin-registry.md` 的 R1 / R3 仍未勾选 | 增加 CLI/UI parity 和 SKILL.md 发布可移植性回归 |
| GAP-002 | P2.5 plugin detail drawer 缺 version dropdown | 手工验收先记录风险,后续补 UI 测试 |
| GAP-003 | P2.6 Team / private marketplace UI 未落地 | 不纳入发布通过项,作为明确未完成范围 |
| GAP-004 | P2.7 trust badge consistency 未完整确认 | Playwright/人工视觉组合验收 |
| GAP-005 | registry v1 DoD 的第三方 fork 工作流还缺 e2e fixture | 用本地 fixture catalog 做替代 smoke真实第三方 publisher 作为发布前人工项 |
| GAP-006 | scenario registry convergence 仍是下一步 | 不阻塞本期插件系统,但 Home chips / Plugins facets / composer search 要抽样对齐 |
## 2. 推荐执行顺序
### 2.1 快速 PR gate
从仓库根目录执行:
```bash
pnpm guard
pnpm typecheck
pnpm --filter @open-design/contracts test
pnpm --filter @open-design/plugin-runtime test
pnpm --filter @open-design/registry-protocol test
pnpm --filter @open-design/daemon test
pnpm --filter @open-design/web test
```
验收标准:
- 所有命令退出码为 `0`
- 如 `@open-design/daemon test` 出现非插件相关历史失败必须在发布记录里列出文件名、失败用例、是否已知不能只写“daemon failed”。
### 2.2 插件聚焦回归
当只想验证本期插件系统,可以先跑这些较高信号文件:
```bash
pnpm --dir apps/daemon exec vitest run -c vitest.config.ts \
tests/plugins-dod-e2e.test.ts \
tests/plugins-headless-run.test.ts \
tests/plugins-e2e-fixture.test.ts \
tests/plugins-apply.test.ts \
tests/plugins-installer.test.ts \
tests/plugins-installer-archive.test.ts \
tests/plugins-marketplaces.test.ts \
tests/plugins-marketplace-doctor.test.ts \
tests/plugins-lockfile.test.ts \
tests/plugins-upgrade.test.ts \
tests/plugins-connector-gate.test.ts \
tests/plugins-tool-token-gate.test.ts \
tests/plugins-pipeline-runner.test.ts \
tests/plugins-code-migration-e2e.test.ts \
tests/plugins-figma-migration-e2e.test.ts \
tests/registry-backends.test.ts
```
```bash
pnpm --dir apps/web exec vitest run -c vitest.config.ts \
tests/components/PluginsView.test.tsx \
tests/components/PluginDetailsModal.dispatch.test.tsx \
tests/components/PluginInputsForm.test.tsx \
tests/components/InlinePluginsRail.test.tsx \
tests/components/HomeHero.plugin-picker.test.tsx \
tests/components/HomeView.plugin-i18n.test.tsx \
tests/components/plugins-home-section.test.tsx \
tests/components/plugins-home-facets.test.ts \
tests/components/MarketplaceView.test.tsx \
tests/router-marketplace.test.ts \
tests/runtime/plugin-source.test.ts
```
```bash
pnpm --filter @open-design/landing-page build
```
验收标准:
- 聚焦文件请使用 `pnpm --dir <package> exec vitest ... <files>`;不要用
`pnpm --filter <package> test -- <files>`,这个仓库里该写法会退化成全量测试。
- daemon 聚焦回归覆盖 install、marketplace、snapshot、pipeline、GenUI、trust gate、lockfile、archive integrity。
- web 聚焦回归覆盖 Plugins tab、detail dispatch、home/plugin picker、marketplace route、plugin source links。
- landing page build 通过,表示 public marketplace/search renderer 仍可静态生成。
### 2.3 用户级 UI smoke
UI smoke 耗时更高,建议发布前跑:
```bash
cd e2e
pnpm exec playwright test -c playwright.config.ts ui/app.test.ts --grep "plugin-create-import"
```
验收标准:
- `Create plugin` 会进入 agent-assisted authoring prompt。
- `Import plugin` 能安装本地 fixture。
- 安装后回到 Installed tab。
- Home `@query` 能选中用户安装插件。
- 创建项目请求携带 `pluginId` 和用户最终 prompt。
### 2.4 本地真实 daemon smoke
选择未占用端口:
```bash
pnpm tools-dev run web --daemon-port 17456 --web-port 17573
```
然后浏览器访问 `http://127.0.0.1:17573`,手工执行:
1. 进入 Plugins。
2. Installed 里确认 official starters 可见。
3. Available 里确认 official 已安装项显示 `Use`,未安装项显示 `Install`
4. Sources 添加一个 raw `open-design-marketplace.json` URL刷新、改 trust、移除。
5. 导入本地 fixture plugin点详情确认 Source、Capabilities、Workflow、Share 菜单可见。
6. Home 里用 `@` 搜索刚导入的 plugin创建项目确认项目消息里出现 plugin chip。
## 3. 自动化测试矩阵
### A. Contract and Schema
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-A01 | Plugin manifest schema | `open-design.json` v1 字段、taskKind、inputs、pipeline、genui、capabilities 可解析 | `packages/contracts/tests/plugins-manifest.test.ts` |
| PS-A02 | Marketplace schema | `official/trusted/restricted` trust vocabularyversions/integrity/publisher 等字段可 passthrough | `packages/contracts/src/plugins/marketplace.ts` + package tests |
| PS-A03 | RegistryBackend protocol | static/GitHub/DB 后端共享 list/search/resolve/publish 语义 | `packages/registry-protocol/tests/backend.test.ts`、`apps/daemon/tests/registry-backends.test.ts` |
| PS-A04 | Plugin block renderer | snapshot 渲染的 prompt block 稳定,不在 daemon/contracts 双份漂移 | `packages/contracts/src/prompts/plugin-block.ts`、`apps/daemon/tests/plugins-dod-e2e.test.ts` |
### B. Runtime Parsing and Portability
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-B01 | SKILL.md-only fallback | `SKILL.md` frontmatter 可合成 schema-valid `PluginManifest` | `packages/plugin-runtime/tests/adapter-agent-skill.test.ts` |
| PS-B02 | Claude plugin adapter | `.claude-plugin/plugin.json` 可作为兼容输入 | `packages/plugin-runtime/tests/parsers.test.ts`、`validate.test.ts` |
| PS-B03 | Sidecar manifest wins | `open-design.json` 覆盖 adapter fallback不复制 SKILL.md body | `packages/plugin-runtime/tests/merge.test.ts` |
| PS-B04 | Deterministic digest | 同一 manifest/source 产出稳定 digest升级后 digest 改变 | `packages/plugin-runtime/tests/digest.test.ts`、`plugins-dod-e2e.test.ts` |
| PS-B05 | Metadata-only preset | 只有 `open-design.json` 的目录必须被 doctor 标为 non-runnable | `apps/daemon/tests/plugins-validate.test.ts`、`plugins-verify.test.ts` |
### C. Install, Apply, Snapshot
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-C01 | Cold local install | local folder 安装到 user plugin rootSQLite 写 installed row | `apps/daemon/tests/plugins-e2e-fixture.test.ts` |
| PS-C02 | Archive install | HTTPS/GitHub archive 解包前校验 `sha256:`mismatch fail closed | `apps/daemon/tests/plugins-installer-archive.test.ts` |
| PS-C03 | Install safety | traversal、symlink、size guard 不允许越界写入 | `apps/daemon/tests/plugins-installer.test.ts` |
| PS-C04 | Pure apply | 连续 apply digest 相同project cwd 不变apply 本身不写 snapshot | `apps/daemon/tests/plugins-dod-e2e.test.ts` |
| PS-C05 | Snapshot writer boundary | `applied_plugin_snapshots` 只由 snapshot/resolver 路径写入 | `apps/daemon/tests/plugins-snapshots.test.ts`、`plugins-dod-e2e.test.ts` |
| PS-C06 | Replay invariance | 插件升级后旧 snapshot prompt block byte-equal | `apps/daemon/tests/plugins-dod-e2e.test.ts` |
| PS-C07 | Snapshot GC | unreferenced snapshot 按 TTL 可清理referenced snapshot pin 住 | `apps/daemon/tests/plugins-snapshot-gc.test.ts` |
| PS-C08 | API fallback reject | daemon 不在路径时plugin run 走 fallback 必须 409 | `apps/daemon/tests/proxy-routes.test.ts` |
### D. CLI and Headless Loop
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-D01 | Headless install -> project -> run | HTTP/CLI 路径都 pin `appliedPluginSnapshotId` | `apps/daemon/tests/plugins-headless-run.test.ts` |
| PS-D02 | CLI prompt injection | `od plugin run` 把 query、inputs、local SKILL.md 注入 agent prompt | `apps/daemon/tests/plugins-headless-run.test.ts` |
| PS-D03 | Project/run/files basics | `od project create`、`od run start/watch/cancel/list/info`、`od files read` 可用 | `apps/daemon/tests/plugins-headless-run.test.ts` + CLI tests |
| PS-D04 | Marketplace CLI | `od marketplace plugins/search/doctor/login` 输出稳定login 只调用 `gh` | `apps/daemon/tests/plugins-headless-run.test.ts`、`plugins-marketplace-doctor.test.ts` |
| PS-D05 | Plugin publish/share | user plugin 进入 publish/contribute workflowGitHub PR payload 稳定 | `apps/daemon/tests/plugins-headless-run.test.ts`、`plugins-publish.test.ts` |
| PS-D06 | Plugin upgrade/yank | upgrade 遵守 policy/lockfileyank 不硬删版本 | `apps/daemon/tests/plugins-upgrade.test.ts`、`plugins-publish.test.ts` |
### E. Registry and Federation
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-E01 | Raw marketplace only | GitHub tree HTML 被 parser 拒绝 | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| PS-E02 | Official seed | official registry 非空trust 为 `official`bundled entries 可 resolve | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| PS-E03 | Community seed | community registry 作为 `restricted` source 加载 | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| PS-E04 | Version resolution | exact、dist-tag、semver range、yanked beta 解析正确 | `apps/daemon/tests/plugins-marketplaces.test.ts` |
| PS-E05 | Provenance | marketplace install 保留 sourceMarketplaceId、entry name/version、resolved ref、integrity | `apps/daemon/tests/plugins-installer.test.ts` |
| PS-E06 | Lockfile replay | `.od/od-plugin-lock.json` 可以重放 exact install | `apps/daemon/tests/plugins-lockfile.test.ts` |
| PS-E07 | Marketplace doctor | invalid name/source/capability/license/yank reason 被报告 | `apps/daemon/tests/plugins-marketplace-doctor.test.ts` |
| PS-E08 | Public site renderer | `/plugins`、detail route、`/plugins/search.json` build 通过 | `pnpm --filter @open-design/landing-page build` |
### F. Pipeline, GenUI, Atoms
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-F01 | First pipeline event | plugin run 的第一批事件包含 `pipeline_stage_started`,早于 agent message chunk | `apps/daemon/tests/plugins-headless-run.test.ts` |
| PS-F02 | Devloop until | `until` evaluator、最大迭代数、失败策略稳定 | `apps/daemon/tests/plugins-until.test.ts`、`plugins-pipeline-runner.test.ts` |
| PS-F03 | GenUI persistence | project-tier answer 跨 conversation 复用,发 cache response | `apps/daemon/tests/plugins-pipeline-runner.test.ts` |
| PS-F04 | GenUI renderer | form/choice/confirmation/oauth-prompt 由产品组件渲染 | `apps/web/tests/components/GenUISurfaceRenderer*.test.tsx` |
| PS-F05 | Auto diff review surface | stage 带 `diff-review` 时自动生成 choice surface | `apps/daemon/tests/plugins-auto-surfaces.test.ts` |
| PS-F06 | Figma migration atoms | `figma-extract`、`token-map` 输出稳定 fixtures | `apps/daemon/tests/plugins-figma-*.test.ts` |
| PS-F07 | Code migration atoms | `code-import`、`design-extract`、`rewrite-plan`、`patch-edit`、`diff-review`、`build-test` 串起来 | `apps/daemon/tests/plugins-code-migration-e2e.test.ts` |
| PS-F08 | Handoff atom | handoff manifest round trippromotion ladder 合法 | `apps/daemon/tests/plugins-handoff*.test.ts` |
### G. Trust, Capability, Security
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-G01 | Restricted capability gate | restricted plugin 缺 `connector:<id>` 时 apply 409 / exit 66 | `apps/daemon/tests/plugins-dod-e2e.test.ts` |
| PS-G02 | Tool token revalidation | 泄漏 token 也不能绕过 connector gate | `apps/daemon/tests/plugins-tool-token-gate.test.ts` |
| PS-G03 | Capability grant/revoke | trust endpoint 可授予/撤销 capability非法 capability 被拒 | `apps/daemon/tests/plugins-trust.test.ts` |
| PS-G04 | Asset sandbox | plugin asset route 不允许路径穿越,返回合适 CSP/content-type | `apps/daemon/tests/plugins-asset-route.test.ts` |
| PS-G05 | API token guard | public bind 没有 `OD_API_TOKEN` 被拒loopback 跳过 bearer | `apps/daemon/tests/api-token-guard.test.ts` |
| PS-G06 | Origin/CORS | daemon route origin validation 不放宽 | `apps/daemon/tests/origin-validation.test.ts`、`server-cors.test.ts` |
### H. Web Product Surface
| ID | 场景 | 核心断言 | 覆盖 |
| --- | --- | --- | --- |
| PS-H01 | Plugins tabs | Installed / Available / Sources / Team tab 可切换 | `apps/web/tests/components/PluginsView.test.tsx` |
| PS-H02 | Available state | 已安装 official 显示 `Use`,未安装显示 `Install`,版本不同显示 upgrade 状态 | `apps/web/tests/components/PluginsView.test.tsx` |
| PS-H03 | Sources operations | add/refresh/remove/trust 调用对应 API wrapper | `apps/web/tests/components/PluginsView.test.tsx` |
| PS-H04 | Create plugin flow | Create plugin 进入 agent-assisted authoring不打开旧 import modal | `apps/web/tests/components/PluginsView.test.tsx`、`e2e/ui/app.test.ts` |
| PS-H05 | Detail modal dispatch | media/html/design/scenario 四种详情入口正确分派 | `apps/web/tests/components/PluginDetailsModal.dispatch.test.tsx` |
| PS-H06 | Detail metadata | Source、capabilities、workflow、GenUI、connectors、author/provenance 可见 | 需补更细组件测试,当前由 detail component + manual 验收覆盖 |
| PS-H07 | Share menu | copy install command / id / link / markdown badgesource/homepage/marketplace link 可用 | `apps/web/tests/components/PluginShareMenu.test.tsx` |
| PS-H08 | Home/Composer apply | Home `@` picker、ChatComposer plugin rail、input form 都能 apply plugin | `HomeHero.plugin-picker.test.tsx`、`InlinePluginsRail.test.tsx`、`PluginInputsForm.test.tsx` |
| PS-H09 | Trust badge consistency | `official/trusted/restricted` 在 card/drawer/source/install confirm 语言一致 | 自动化不足,发布前手工验收 |
## 4. 手工验收清单
### 4.1 Plugin detail drawer
| ID | 步骤 | 期望 |
| --- | --- | --- |
| MAN-001 | 打开一个 official scenario plugin 详情 | 标题、版本、trust、source、workflow、capabilities 都可读 |
| MAN-002 | 打开一个 marketplace-installed plugin 详情 | provenance 显示 sourceMarketplaceId / entry name / source kind |
| MAN-003 | 打开 Share 菜单,复制 install command | 剪贴板内容为 `od plugin install <plugin-or-source>`,不是 marketplace id 误当 plugin id |
| MAN-004 | 打开带 inputs 的 plugin | inputs 类型、required、default、options 都显示 |
| MAN-005 | 尝试查找 version dropdown | 当前预期:缺失,记录为 P2.5 未完成 |
### 4.2 Sources / Available / Team
| ID | 步骤 | 期望 |
| --- | --- | --- |
| MAN-006 | Sources 添加 raw marketplace JSON | 成功加入 restricted source列表显示 catalog name 和 plugin count |
| MAN-007 | Sources 添加 GitHub tree 页面 | 被拒绝,错误文案指向 marketplace JSON 解析失败 |
| MAN-008 | Sources 切 trust 为 trusted再刷新 | trust 保存Available 卡片继承新的 catalog trust 语义 |
| MAN-009 | Available 安装远程 entry | installed record 保留 marketplace provenance |
| MAN-010 | Team tab | 当前预期:展示 coming soon不宣称 private marketplace 已完成 |
### 4.3 Headless real workflow
| ID | 命令 | 期望 |
| --- | --- | --- |
| MAN-011 | `od plugin install <local-plugin>` | 输出 ok`od plugin list --json` 能看到新 plugin |
| MAN-012 | `od plugin doctor <id> --json` | valid plugin 无 errormetadata-only plugin 有明确 non-runnable 诊断 |
| MAN-013 | `od project create --plugin <id> --inputs '{"topic":"qa"}' --json` | 返回 project id 和 `appliedPluginSnapshotId` |
| MAN-014 | `od plugin run <id> --project <projectId> --follow` | 事件流包含 pipeline stage、agent events、end status |
| MAN-015 | `od marketplace search "<query>" --json` | 搜索 configured catalog不依赖 web UI |
### 4.4 Public registry / self-host
| ID | 步骤 | 期望 |
| --- | --- | --- |
| MAN-016 | `pnpm --filter @open-design/landing-page build` | 静态 `/plugins``search.json` 生成成功 |
| MAN-017 | 复制 `plugins/registry/community/open-design-marketplace.json` 到临时 URL 或本地 fixture server | daemon 能 add/search/install |
| MAN-018 | 按 `docs/self-hosting-a-registry.md` 新建第三方 catalog | 只需替换 catalog name/url/source 两类配置,不改 daemon/web 代码 |
| MAN-019 | 用 `od plugin publish --to marketplace-json --catalog <path>` | catalog 稳定 upsertsource 可复现 |
## 5. 发布通过标准
本期插件系统可以标为“插件运行时 v1 ready”的条件
1. `plugins-implementation.md` §8 的 8 个 e2e gate 都通过。
2. `pnpm guard``pnpm typecheck` 通过。
3. contract/runtime/registry-protocol/daemon/web/landing-page 的推荐命令通过。
4. 至少跑过一次 `plugin-create-import` Playwright smoke。
5. 手工确认 P2.5/P2.6/P2.7 的状态:完成则更新 plan 勾选;未完成则在发布说明里列为 deferred。
Registry v1 只有在以下额外条件满足后才能标为“fully done”
1. `plugin-registry.md` §4 DoD 全部勾选。
2. 有一个 e2e fixture catalog 验证第三方 fork/self-host source。
3. UI Sources/Available 的每个动作都有等价 CLI 命令,并有 parity 测试或脚本证明。
4. 至少一次真实第三方 publisher 通过 `od plugin publish` 发起发布流程,没有手写 JSON。
## 6. 失败排查顺序
| 现象 | 优先检查 |
| --- | --- |
| manifest/schema 测试失败 | `packages/contracts/src/plugins/*``packages/plugin-runtime/src/validate.ts` |
| install 成功但 Available/Installed 状态不对 | installed record 的 `sourceMarketplaceEntryName`、`sourceMarketplaceId`、`marketplaceTrust` |
| apply 需要重复输入或 snapshot 丢失 | `resolve-snapshot.ts` 的 project-pinned fallback 和 `snapshots.ts` |
| pipeline 事件缺失 | `firePipelineForRun()` 是否在 `POST /api/runs` 路径触发 |
| connector token 绕过 | `connector-gate.ts`、`tool-tokens.ts`、`/api/tools/connectors/execute` 二次校验 |
| UI 装完 plugin 后找不到 | `PluginsView` tab/test id、`buildAvailablePlugins()` name matching |
| public registry 页面缺条目 | `plugins/registry/*/open-design-marketplace.json`、`apps/landing-page/app/plugin-registry.ts` |
## 7. 维护规则
1. 每次插件系统 PR 合入,若新增能力或测试文件,更新本文件对应矩阵。
2. 若 `docs/plans/plugins-implementation.md``docs/plans/plugin-registry.md` 勾选状态变化,同步更新 §1 进度摘要。
3. 不把主观视觉验收伪装成自动化通过项。视觉和真实第三方发布流程保留在 MAN 用例。
4. 自动化测试优先放在所有者目录daemon 行为进 `apps/daemon/tests/`web 组件进 `apps/web/tests/`,跨 app/user flow 进 `e2e/`

View file

@ -745,7 +745,7 @@ async function runPluginCreateImportFlow(
expect((await installResponse).ok()).toBeTruthy();
await expect(page.getByText('Installed Query Plugin.')).toBeVisible();
await expect(page.getByTestId('plugins-tab-mine')).toHaveAttribute('aria-selected', 'true');
await expect(page.getByTestId('plugins-tab-installed')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('[data-plugin-id="query-plugin"]')).toBeVisible();
await page.goto('/');

View file

@ -116,6 +116,10 @@ export interface ProjectMetadata {
// Batch/API-created projects can opt out of the initial discovery form so
// the first agent turn builds immediately from the submitted brief.
skipDiscoveryBrief?: boolean;
// Plugins selected through @ mentions on Home. These are additive
// context references; the explicit "Use plugin" snapshot, when present,
// remains the primary executable plugin for the run.
contextPlugins?: Array<{ id: string; title: string; description?: string }>;
}
export interface Project {

View file

@ -45,6 +45,12 @@ export const AppliedPluginSnapshotSchema = z.object({
pluginVersion: z.string(),
manifestSourceDigest: z.string(),
sourceMarketplaceId: z.string().optional(),
sourceMarketplaceEntryName: z.string().optional(),
sourceMarketplaceEntryVersion: z.string().optional(),
marketplaceTrust: z.enum(['official', 'trusted', 'restricted']).optional(),
resolvedSource: z.string().optional(),
resolvedRef: z.string().optional(),
archiveIntegrity: z.string().optional(),
pinnedRef: z.string().optional(),
inputs: z.record(z.union([z.string(), z.number(), z.boolean()])),
resolvedContext: ResolvedContextSchema,

View file

@ -1,6 +1,11 @@
import { z } from 'zod';
import { PluginManifestSchema } from './manifest.js';
import { TrustTierSchema, type TrustTier } from './marketplace.js';
import {
MarketplaceTrustSchema,
TrustTierSchema,
type MarketplaceTrust,
type TrustTier,
} from './marketplace.js';
// `installed_plugins.source_kind` — accepts `'bundled'` from Phase 1 even
// though `plugins/_official/` arrives in spec §23 / Phase 4 (plan F3). Keeps
@ -26,6 +31,13 @@ export const InstalledPluginRecordSchema = z.object({
pinnedRef: z.string().optional(),
sourceDigest: z.string().optional(),
sourceMarketplaceId: z.string().optional(),
sourceMarketplaceEntryName: z.string().optional(),
sourceMarketplaceEntryVersion: z.string().optional(),
marketplaceTrust: MarketplaceTrustSchema.optional(),
resolvedSource: z.string().optional(),
resolvedRef: z.string().optional(),
manifestDigest: z.string().optional(),
archiveIntegrity: z.string().optional(),
trust: TrustTierSchema,
capabilitiesGranted: z.array(z.string()),
manifest: PluginManifestSchema,
@ -67,4 +79,4 @@ export type ProjectPluginFolderInstallRequest = z.infer<typeof ProjectPluginFold
// Re-export TrustTier so consumers can pull every plugin contract from one
// barrel without hopping through marketplace.ts.
export type { TrustTier };
export type { MarketplaceTrust, TrustTier };

View file

@ -31,7 +31,7 @@ export type McpServerSpec = z.infer<typeof McpServerSpecSchema>;
export const InputFieldSchema = z.object({
name: z.string().min(1),
label: z.string().optional(),
type: z.enum(['string', 'text', 'select', 'number', 'boolean']).optional(),
type: z.enum(['string', 'text', 'select', 'number', 'boolean', 'file']).optional(),
required: z.boolean().optional(),
options: z.array(z.string()).optional(),
placeholder: z.string().optional(),

View file

@ -4,6 +4,28 @@ import {
OpenDesignSpecVersionSchema,
} from './manifest.js';
const MarketplaceEntryDistSchema = z.object({
type: z.string().optional(),
archive: z.string().optional(),
integrity: z.string().optional(),
manifestDigest: z.string().optional(),
}).passthrough();
const MarketplacePluginVersionSchema = z.object({
version: z.string().min(1),
source: z.string().min(1).optional(),
ref: z.string().optional(),
dist: MarketplaceEntryDistSchema.optional(),
integrity: z.string().optional(),
manifestDigest: z.string().optional(),
deprecated: z.union([z.boolean(), z.string()]).optional(),
yanked: z.boolean().optional(),
yankedAt: z.string().optional(),
yankReason: z.string().optional(),
}).passthrough();
export type MarketplacePluginVersion = z.infer<typeof MarketplacePluginVersionSchema>;
// `open-design-marketplace.json` schema (v1). Mirrors
// `docs/schemas/open-design.marketplace.v1.json`. The federated catalog
// format is intentionally permissive — community catalogs can carry extra
@ -13,6 +35,23 @@ export const MarketplacePluginEntrySchema = z.object({
source: z.string().min(1),
version: z.string().min(1),
ref: z.string().optional(),
dist: MarketplaceEntryDistSchema.optional(),
versions: z.array(MarketplacePluginVersionSchema).optional(),
distTags: z.record(z.string()).optional(),
integrity: z.string().optional(),
manifestDigest: z.string().optional(),
publisher: z.object({
id: z.string().optional(),
github: z.string().optional(),
url: z.string().optional(),
}).passthrough().optional(),
homepage: z.string().optional(),
license: z.string().optional(),
capabilitiesSummary: z.array(z.string()).optional(),
deprecated: z.union([z.boolean(), z.string()]).optional(),
yanked: z.boolean().optional(),
yankedAt: z.string().optional(),
yankReason: z.string().optional(),
tags: z.array(z.string()).optional(),
title: z.string().optional(),
description: z.string().optional(),
@ -45,5 +84,5 @@ export type MarketplaceManifest = z.infer<typeof MarketplaceManifestSchema>;
export const TrustTierSchema = z.enum(['bundled', 'trusted', 'restricted']);
export type TrustTier = z.infer<typeof TrustTierSchema>;
export const MarketplaceTrustSchema = z.enum(['official', 'trusted', 'untrusted']);
export const MarketplaceTrustSchema = z.enum(['official', 'trusted', 'restricted']);
export type MarketplaceTrust = z.infer<typeof MarketplaceTrustSchema>;

View file

@ -521,6 +521,25 @@ function renderMetadataBlock(
);
}
if (Array.isArray(metadata.contextPlugins) && metadata.contextPlugins.length > 0) {
lines.push('');
lines.push('### @ plugin context');
lines.push(
'The user selected these plugins as additive context via @ mentions. Treat them as requested references to combine with the brief; only the explicit active plugin block, if present, is the executable/pinned plugin snapshot.',
);
for (const plugin of metadata.contextPlugins) {
const id = typeof plugin.id === 'string' ? plugin.id : '';
const title = typeof plugin.title === 'string' && plugin.title.trim().length > 0
? plugin.title.trim()
: id;
if (!id && !title) continue;
const description = typeof plugin.description === 'string' && plugin.description.trim().length > 0
? `${plugin.description.trim()}`
: '';
lines.push(`- ${title}${id ? ` (\`${id}\`)` : ''}${description}`);
}
}
// Curated prompt template reference for image/video projects. Inlined
// verbatim (with light truncation) so the agent can borrow structure,
// mood and phrasing without a separate fetch. The user may have edited

View file

@ -134,7 +134,8 @@ function mapInputs(value: FrontmatterValue | undefined, warnings: string[]): Inp
let mappedType: InputField['type'];
if (t === 'integer') mappedType = 'number';
else if (t === 'enum') mappedType = 'select';
else if (t === 'string' || t === 'text' || t === 'select' || t === 'number' || t === 'boolean') mappedType = t;
else if (t === 'upload') mappedType = 'file';
else if (t === 'string' || t === 'text' || t === 'select' || t === 'number' || t === 'boolean' || t === 'file') mappedType = t;
else {
warnings.push(`SKILL.md inputs[${name}].type='${t}' is not in the v1 input vocabulary; falling back to 'string'`);
mappedType = 'string';

View file

@ -0,0 +1,14 @@
import { build } from "esbuild";
await build({
bundle: true,
entryNames: "[dir]/[name]",
entryPoints: ["./src/index.ts"],
format: "esm",
outbase: "./src",
outdir: "./dist",
outExtension: { ".js": ".mjs" },
packages: "external",
platform: "node",
target: "node24",
});

View file

@ -0,0 +1,31 @@
{
"name": "@open-design/registry-protocol",
"version": "0.5.0",
"private": true,
"type": "module",
"description": "Pure TypeScript registry backend protocol for Open Design plugin sources.",
"main": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
}
},
"scripts": {
"build": "node ./esbuild.config.mjs && tsc -p tsconfig.json --emitDeclarationOnly",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"esbuild": "0.27.7",
"typescript": "^5.6.3",
"vitest": "^2.1.8"
}
}

View file

@ -0,0 +1,33 @@
import type {
RegistryBackendKind,
RegistryDoctorReport,
RegistryEntry,
RegistryListFilter,
RegistryPublishOutcome,
RegistryPublishRequest,
RegistrySearchQuery,
RegistrySearchResult,
RegistryTrust,
RegistryYankOutcome,
ResolvedRegistryEntry,
} from './schemas.js';
export interface RegistryBackend {
readonly id: string;
readonly kind: RegistryBackendKind;
readonly trust: RegistryTrust;
list(filter?: RegistryListFilter): Promise<RegistryEntry[]>;
search(query: RegistrySearchQuery): Promise<RegistrySearchResult[]>;
resolve(name: string, range?: string): Promise<ResolvedRegistryEntry | null>;
manifest(name: string, version: string): Promise<RegistryEntry | null>;
doctor(): Promise<RegistryDoctorReport>;
publish?(request: RegistryPublishRequest): Promise<RegistryPublishOutcome>;
yank?(name: string, version: string, reason: string): Promise<RegistryYankOutcome>;
}
export interface RegistryBackendFactory<TConfig = unknown> {
readonly kind: RegistryBackendKind;
create(config: TConfig): RegistryBackend;
}

View file

@ -0,0 +1,2 @@
export * from './backend.js';
export * from './schemas.js';

View file

@ -0,0 +1,164 @@
import { z } from 'zod';
export const RegistryBackendKindSchema = z.enum(['github', 'http', 'local', 'db']);
export type RegistryBackendKind = z.infer<typeof RegistryBackendKindSchema>;
export const RegistryTrustSchema = z.enum(['official', 'trusted', 'restricted']);
export type RegistryTrust = z.infer<typeof RegistryTrustSchema>;
export const RegistryDistSchema = z.object({
type: z.enum(['github-release', 'https-archive', 'local-archive', 'database']).optional(),
archive: z.string().min(1).optional(),
integrity: z.string().min(1).optional(),
manifestDigest: z.string().min(1).optional(),
}).passthrough();
export type RegistryDist = z.infer<typeof RegistryDistSchema>;
export const RegistryPublisherSchema = z.object({
id: z.string().min(1).optional(),
name: z.string().min(1).optional(),
github: z.string().min(1).optional(),
url: z.string().min(1).optional(),
verified: z.boolean().optional(),
}).passthrough();
export type RegistryPublisher = z.infer<typeof RegistryPublisherSchema>;
export const RegistryMetricsSchema = z.object({
downloads: z.number().int().nonnegative().optional(),
installs: z.number().int().nonnegative().optional(),
stars: z.number().int().nonnegative().optional(),
updatedAt: z.string().optional(),
lastPublishedAt: z.string().optional(),
}).passthrough();
export type RegistryMetrics = z.infer<typeof RegistryMetricsSchema>;
export const RegistrySignatureSchema = z.object({
kind: z.enum(['github-oidc', 'cosign', 'minisign', 'custom']),
issuer: z.string().min(1).optional(),
subject: z.string().min(1).optional(),
signature: z.string().min(1),
certificate: z.string().min(1).optional(),
signedAt: z.string().optional(),
}).passthrough();
export type RegistrySignature = z.infer<typeof RegistrySignatureSchema>;
export const RegistryVersionSchema = z.object({
version: z.string().min(1),
source: z.string().min(1).optional(),
ref: z.string().min(1).optional(),
dist: RegistryDistSchema.optional(),
integrity: z.string().min(1).optional(),
manifestDigest: z.string().min(1).optional(),
deprecated: z.union([z.boolean(), z.string()]).optional(),
yanked: z.boolean().optional(),
yankedAt: z.string().optional(),
yankReason: z.string().optional(),
}).passthrough();
export type RegistryVersion = z.infer<typeof RegistryVersionSchema>;
export const RegistryEntrySchema = z.object({
name: z.string().regex(/^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/),
version: z.string().min(1),
source: z.string().min(1),
ref: z.string().min(1).optional(),
title: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
capabilitiesSummary: z.array(z.string()).optional(),
dist: RegistryDistSchema.optional(),
versions: z.array(RegistryVersionSchema).optional(),
distTags: z.record(z.string()).optional(),
integrity: z.string().min(1).optional(),
manifestDigest: z.string().min(1).optional(),
publisher: RegistryPublisherSchema.optional(),
homepage: z.string().optional(),
license: z.string().optional(),
deprecated: z.union([z.boolean(), z.string()]).optional(),
yanked: z.boolean().optional(),
yankedAt: z.string().optional(),
yankReason: z.string().optional(),
metrics: RegistryMetricsSchema.optional(),
signatures: z.array(RegistrySignatureSchema).optional(),
}).passthrough();
export type RegistryEntry = z.infer<typeof RegistryEntrySchema>;
export const RegistryListFilterSchema = z.object({
query: z.string().optional(),
tags: z.array(z.string()).optional(),
publisher: z.string().optional(),
includeYanked: z.boolean().optional(),
}).optional();
export type RegistryListFilter = z.infer<typeof RegistryListFilterSchema>;
export const RegistrySearchQuerySchema = z.object({
query: z.string().default(''),
tags: z.array(z.string()).optional(),
limit: z.number().int().positive().max(500).optional(),
includeYanked: z.boolean().optional(),
});
export type RegistrySearchQuery = z.infer<typeof RegistrySearchQuerySchema>;
export const RegistrySearchResultSchema = z.object({
entry: RegistryEntrySchema,
score: z.number().nonnegative(),
matched: z.array(z.string()),
});
export type RegistrySearchResult = z.infer<typeof RegistrySearchResultSchema>;
export const ResolvedRegistryEntrySchema = z.object({
backendId: z.string().min(1),
backendKind: RegistryBackendKindSchema,
trust: RegistryTrustSchema,
entry: RegistryEntrySchema,
version: RegistryVersionSchema,
source: z.string().min(1),
ref: z.string().optional(),
integrity: z.string().optional(),
manifestDigest: z.string().optional(),
});
export type ResolvedRegistryEntry = z.infer<typeof ResolvedRegistryEntrySchema>;
export const RegistryPublishRequestSchema = z.object({
entry: RegistryEntrySchema,
packagePath: z.string().optional(),
dryRun: z.boolean().optional(),
tag: z.string().optional(),
changelog: z.string().optional(),
});
export type RegistryPublishRequest = z.infer<typeof RegistryPublishRequestSchema>;
export const RegistryPublishOutcomeSchema = z.object({
ok: z.boolean(),
dryRun: z.boolean().optional(),
pullRequestUrl: z.string().optional(),
changedFiles: z.array(z.string()).default([]),
warnings: z.array(z.string()).default([]),
});
export type RegistryPublishOutcome = z.infer<typeof RegistryPublishOutcomeSchema>;
export const RegistryDoctorIssueSchema = z.object({
severity: z.enum(['error', 'warning', 'info']),
code: z.string().min(1),
message: z.string().min(1),
pluginName: z.string().optional(),
});
export type RegistryDoctorIssue = z.infer<typeof RegistryDoctorIssueSchema>;
export const RegistryDoctorReportSchema = z.object({
ok: z.boolean(),
backendId: z.string().min(1),
checkedAt: z.number(),
entriesChecked: z.number().int().nonnegative(),
issues: z.array(RegistryDoctorIssueSchema),
});
export type RegistryDoctorReport = z.infer<typeof RegistryDoctorReportSchema>;
export const RegistryYankOutcomeSchema = z.object({
ok: z.boolean(),
name: z.string().min(1),
version: z.string().min(1),
reason: z.string().min(1),
pullRequestUrl: z.string().optional(),
warnings: z.array(z.string()).default([]),
});
export type RegistryYankOutcome = z.infer<typeof RegistryYankOutcomeSchema>;

View file

@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
RegistryEntrySchema,
RegistryPublishOutcomeSchema,
type RegistryBackend,
} from '../src/index.js';
const entry = RegistryEntrySchema.parse({
name: 'vendor/example',
version: '1.0.0',
source: 'github:vendor/example@v1.0.0/plugin',
title: 'Example',
capabilitiesSummary: ['prompt:inject'],
});
describe('registry protocol', () => {
it('requires stable vendor/plugin-name ids', () => {
expect(() => RegistryEntrySchema.parse({ ...entry, name: 'example' })).toThrow();
expect(RegistryEntrySchema.parse(entry).name).toBe('vendor/example');
});
it('accepts optional metrics and marketplace signatures for future hardening', () => {
const parsed = RegistryEntrySchema.parse({
...entry,
metrics: {
downloads: 42,
installs: 7,
},
signatures: [
{
kind: 'github-oidc',
issuer: 'https://token.actions.githubusercontent.com',
subject: 'repo:vendor/example:ref:refs/heads/main',
signature: 'sha256-fixture',
},
],
});
expect(parsed.metrics?.downloads).toBe(42);
expect(parsed.signatures?.[0]?.kind).toBe('github-oidc');
});
it('keeps all backend implementations behind one async contract', async () => {
const backend: RegistryBackend = {
id: 'fixture',
kind: 'local',
trust: 'restricted',
async list() {
return [entry];
},
async search(query) {
return query.query === 'Example'
? [{ entry, score: 1, matched: ['title'] }]
: [];
},
async resolve(name) {
if (name !== entry.name) return null;
return {
backendId: this.id,
backendKind: this.kind,
trust: this.trust,
entry,
version: { version: entry.version, source: entry.source },
source: entry.source,
};
},
async manifest(name) {
return name === entry.name ? entry : null;
},
async doctor() {
return {
ok: true,
backendId: this.id,
checkedAt: 123,
entriesChecked: 1,
issues: [],
};
},
async publish(request) {
return RegistryPublishOutcomeSchema.parse({
ok: true,
dryRun: request.dryRun,
changedFiles: [`plugins/${request.entry.name}/versions/${request.entry.version}.json`],
warnings: [],
});
},
};
await expect(backend.list()).resolves.toHaveLength(1);
await expect(backend.search({ query: 'Example' })).resolves.toHaveLength(1);
await expect(backend.resolve('vendor/example')).resolves.toMatchObject({
backendId: 'fixture',
source: entry.source,
});
await expect(backend.doctor()).resolves.toMatchObject({ ok: true, entriesChecked: 1 });
await expect(backend.publish?.({ entry, dryRun: true })).resolves.toMatchObject({
ok: true,
dryRun: true,
});
});
});

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"rootDir": "src",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View file

@ -5,6 +5,8 @@ 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/` - community plugin source folders. These are installable plugins, but they are not preinstalled unless a registry entry points at them and the user installs one.
- `registry/` - default registry source manifests (`open-design-marketplace.json`) for official and community catalogs. These feed the Plugins Available/Sources UI.
- `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 versioned `open-design.json` sidecar that gives Open Design marketplace metadata, inputs, previews, pipelines, and trust/capability hints.

View file

@ -5,6 +5,8 @@
这个目录有两类职责:
- `_official/` - Open Design 随包发布的一方插件。daemon 启动时会扫描这个目录,并把这些插件注册为 official。
- `community/` - 社区插件源码目录。这里的插件可安装,但不会预装;只有 registry entry 指向它们并由用户安装后才会进入 Installed。
- `registry/` - 默认 registry source manifests`open-design-marketplace.json`),包含 official 和 community catalog用来驱动 Plugins 的 Available / Sources UI。
- `spec/` - 可移植插件规范、模板、示例和 agent handoff 包,用于构建、测试、发布插件,或向 Open Design 提交 PR。
所有插件共享同一个基础契约:插件是一个可移植的 agent skill 文件夹,包含 `SKILL.md`,并可选添加带版本的 `open-design.json` sidecar。`open-design.json` 负责 Open Design marketplace 元数据、输入项、预览、pipeline、信任与能力声明。

View file

@ -3,7 +3,7 @@
"specVersion": "1.0.0",
"name": "example-hyperframes",
"title": "Hyperframes",
"version": "0.1.0",
"version": "0.1.1",
"description": "Create video compositions, animations, title cards, overlays, captions, voiceovers, audio-reactive visuals, and scene transitions in HyperFrames HTML. Use when asked to build any HTML-based video content, add captions or subtitles synced to audio, generate text-to-speech narration, create audio-reactive animation (beat sync, glow, pulse driven by music), add animated text highlighting (marker sweeps, hand-drawn circles, burst lines, scribble, sketchout), or add transitions between scenes (crossfades, wipes, reveals, shader transitions). Covers composition authoring, timing, media, and the full video production workflow. For CLI commands (init, lint, preview, render, transcribe, tts) see the hyperframes-cli skill.",
"license": "MIT",
"author": {
@ -48,10 +48,67 @@
},
"useCase": {
"query": {
"en": "A 5-second product reveal: a minimal high-end product on a clean cream\nsurface, soft side light, slow camera push-in, restrained motion, no\ntext overlays.",
"zh-CN": "使用这个插件完成以下任务:A 5-second product reveal: a minimal high-end product on a clean cream\nsurface, soft side light, slow camera push-in, restrained motion, no\ntext overlays."
"en": "Create a {{duration}}-second {{format}} HyperFrames composition for {{subject}}. Aspect: {{aspect}}. Visual style: {{style}}. Audio/captions: {{audioPlan}}. Use the HyperFrames HTML workflow, deterministic timelines, and the referenced motion guides.",
"zh-CN": "使用这个插件完成以下任务:为 {{subject}} 创建一个 {{duration}} 秒的 {{format}} HyperFrames 作品。画幅:{{aspect}}。视觉风格:{{style}}。音频/字幕:{{audioPlan}}。使用 HyperFrames HTML 工作流、确定性时间线和引用的运动指南。"
}
},
"inputs": [
{
"name": "format",
"label": "Format",
"type": "select",
"required": true,
"options": [
"product reveal",
"captioned short",
"logo outro",
"audio-reactive visual",
"scene transition sequence"
],
"default": "product reveal"
},
{
"name": "subject",
"label": "Subject",
"type": "string",
"required": true,
"placeholder": "a premium AI note-taking app",
"default": "a polished product concept"
},
{
"name": "duration",
"label": "Duration",
"type": "number",
"required": true,
"default": 5
},
{
"name": "aspect",
"label": "Aspect ratio",
"type": "select",
"required": true,
"options": [
"16:9",
"9:16",
"1:1"
],
"default": "16:9"
},
{
"name": "style",
"label": "Style",
"type": "string",
"placeholder": "minimal premium, soft side light, restrained motion",
"default": "minimal premium motion"
},
{
"name": "audioPlan",
"label": "Audio/captions",
"type": "string",
"placeholder": "muted, TTS narration, captions from transcript",
"default": "no audio or captions unless requested"
}
],
"context": {
"skills": [
{

View file

@ -3,7 +3,7 @@
"specVersion": "1.0.0",
"name": "example-simple-deck",
"title": "Simple Deck",
"version": "0.1.0",
"version": "0.1.1",
"description": "Single-file horizontal-swipe HTML deck. Built by copying the seed\n`assets/template.html` (which carries the proven 5-rule iframe nav script)\nand pasting slide layouts from `references/layouts.md`. Pitch decks,\nproduct overviews, study material — when you don't need the magazine\naesthetic of `magazine-web-ppt`.",
"license": "MIT",
"author": {
@ -41,8 +41,8 @@
},
"useCase": {
"query": {
"en": "Single-file horizontal-swipe HTML deck. Built by copying the seed `assets/template.html` (which carries the proven 5-rule iframe nav script) and pasting slide layouts from `references/layouts.md`. Pitch decks, product overviews, study material — when you don't need the magazine aesthetic of `magazine-web-ppt`.",
"zh-CN": "使用这个插件完成以下任务:Single-file horizontal-swipe HTML deck. Built by copying the seed `assets/template.html` (which carries the proven 5-rule iframe nav script) and pasting slide layouts from `references/layouts.md`. Pitch decks, product overviews, study material — when you don't need the magazine aesthetic of `magazine-web-ppt`."
"en": "Create a {{slideCount}}-slide {{deckType}} for {{audience}} about {{topic}}. Speaker notes: {{speakerNotes}}. Use {{designSystem}} as the design-system direction. Build a single-file horizontal-swipe HTML deck by copying `assets/template.html` and pasting slide layouts from `references/layouts.md`.",
"zh-CN": "使用这个插件完成以下任务:为 {{audience}} 创建一个 {{slideCount}} 页的 {{deckType}},主题是 {{topic}}。演讲者备注:{{speakerNotes}}。设计系统方向使用 {{designSystem}}。复制 `assets/template.html` 并从 `references/layouts.md` 粘贴版面,构建单文件横向滑动 HTML deck。"
},
"exampleOutputs": [
{
@ -51,6 +51,62 @@
}
]
},
"inputs": [
{
"name": "deckType",
"label": "Deck type",
"type": "select",
"required": true,
"options": [
"pitch deck",
"product overview",
"study deck",
"strategy deck",
"sales deck"
],
"default": "pitch deck"
},
{
"name": "topic",
"label": "Topic",
"type": "string",
"required": true,
"placeholder": "AI operations platform for modern support teams",
"default": "the user's brief"
},
{
"name": "audience",
"label": "Audience",
"type": "string",
"required": true,
"placeholder": "Series A investors",
"default": "decision makers"
},
{
"name": "slideCount",
"label": "Slide count",
"type": "number",
"required": true,
"default": 10
},
{
"name": "speakerNotes",
"label": "Speaker notes",
"type": "select",
"options": [
"include speaker notes",
"no speaker notes"
],
"default": "include speaker notes"
},
{
"name": "designSystem",
"label": "Design system",
"type": "string",
"placeholder": "Swiss, Linear, editorial, or active project design system",
"default": "the active project design system"
}
],
"context": {
"skills": [
{

View file

@ -3,7 +3,7 @@
"specVersion": "1.0.0",
"name": "example-web-prototype",
"title": "Web Prototype",
"version": "0.1.0",
"version": "0.1.1",
"description": "General-purpose desktop web prototype. Single self-contained HTML file built\nby copying the seed `assets/template.html` and pasting section layouts from\n`references/layouts.md`. Default for any landing / marketing / docs / SaaS\npage when no more specific skill matches.",
"license": "MIT",
"author": {
@ -44,8 +44,8 @@
},
"useCase": {
"query": {
"en": "General-purpose desktop web prototype. Single self-contained HTML file built by copying the seed `assets/template.html` and pasting section layouts from `references/layouts.md`. Default for any landing / marketing / docs / SaaS page when no more specific skill matches.",
"zh-CN": "使用这个插件完成以下任务:General-purpose desktop web prototype. Single self-contained HTML file built by copying the seed `assets/template.html` and pasting section layouts from `references/layouts.md`. Default for any landing / marketing / docs / SaaS page when no more specific skill matches."
"en": "Build a {{fidelity}} {{artifactKind}} for {{audience}}. Use {{designSystem}} as the design-system direction and start from {{template}}. Single self-contained HTML file built by copying the seed `assets/template.html` and pasting section layouts from `references/layouts.md`.",
"zh-CN": "使用这个插件完成以下任务:为 {{audience}} 构建一个 {{fidelity}} 的 {{artifactKind}}。设计系统方向使用 {{designSystem}},从 {{template}} 开始。使用 `assets/template.html` 种子并从 `references/layouts.md` 粘贴版面,输出单文件 HTML。"
},
"exampleOutputs": [
{
@ -54,6 +54,49 @@
}
]
},
"inputs": [
{
"name": "artifactKind",
"label": "Artifact kind",
"type": "string",
"required": true,
"placeholder": "SaaS landing page",
"default": "web prototype"
},
{
"name": "fidelity",
"label": "Fidelity",
"type": "select",
"required": true,
"options": [
"wireframe",
"high-fidelity"
],
"default": "high-fidelity"
},
{
"name": "audience",
"label": "Audience",
"type": "string",
"required": true,
"placeholder": "startup founders evaluating an AI CRM",
"default": "product evaluators"
},
{
"name": "designSystem",
"label": "Design system",
"type": "string",
"placeholder": "OpenAI, Linear, shadcn, or custom brand notes",
"default": "the active project design system"
},
{
"name": "template",
"label": "Template",
"type": "string",
"placeholder": "marketing homepage, dashboard, docs page",
"default": "the bundled web prototype seed"
}
],
"context": {
"skills": [
{

View file

@ -0,0 +1,5 @@
{
"name": "community-import-smoke-test",
"description": "A portable community plugin for validating Open Design plugin import flows.",
"version": "0.1.0"
}

View file

@ -0,0 +1,19 @@
# Community Import Smoke Test
A small community plugin for exercising the Open Design import UI. It is meant
to be boring in exactly the useful way: the folder has the portable `SKILL.md`
floor, the enriched `open-design.json` sidecar, and a minimal Claude-compatible
plugin manifest.
## Test Paths
- Upload folder: choose `plugins/community/import-smoke-test`.
- Upload zip: package this folder and upload the archive.
- From GitHub: use `github:nexu-io/open-design@garnet-hemisphere/plugins/community/import-smoke-test` after the branch is available remotely.
- Marketplace name: use `community/import-smoke-test` after the community marketplace has been registered or refreshed.
## Expected Result
Open Design should install the plugin as a user/community plugin, preserve the
source provenance, and show the plugin with the title `Community Import Smoke
Test`.

View file

@ -0,0 +1,21 @@
---
name: community-import-smoke-test
description: A portable community plugin for validating Open Design plugin import flows.
---
# Community Import Smoke Test
Use this plugin when validating that Open Design can import community plugins
from a local folder, a zip archive, a GitHub subpath, or a marketplace entry.
When this plugin is applied:
1. Identify the import path the user is testing: folder, zip, GitHub, or marketplace.
2. Produce a compact import receipt that includes the plugin name, source kind,
files detected, and any user note.
3. Keep the output intentionally small so install, apply, and provenance states
are easy to inspect in the UI.
Do not require network access, shell commands, external connectors, or secrets.
This plugin exists to exercise install and apply plumbing, not to perform a
production workflow.

View file

@ -0,0 +1,7 @@
# Import Checklist
- `SKILL.md` is present for portable agent execution.
- `open-design.json` is present for Open Design marketplace metadata.
- `.claude-plugin/plugin.json` is present for Claude-compatible plugin checks.
- Preview HTML is present so the marketplace detail page can render a simple
example output.

View file

@ -0,0 +1,124 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "community-import-smoke-test",
"title": "Community Import Smoke Test",
"version": "0.1.0",
"description": "A portable community plugin used to verify folder, zip, GitHub subpath, and marketplace import flows.",
"license": "MIT",
"author": {
"name": "Open Design Community",
"url": "https://open-design.ai/marketplace/community"
},
"homepage": "https://github.com/nexu-io/open-design/tree/garnet-hemisphere/plugins/community/import-smoke-test",
"plugin": {
"repo": "https://github.com/nexu-io/open-design/tree/garnet-hemisphere/plugins/community/import-smoke-test"
},
"tags": [
"community",
"import",
"smoke-test",
"marketplace"
],
"compat": {
"agentSkills": [
{
"path": "./SKILL.md"
}
],
"claudePlugins": [
{
"path": "./.claude-plugin/plugin.json"
}
]
},
"od": {
"kind": "skill",
"taskKind": "new-generation",
"mode": "prototype",
"platform": "desktop",
"scenario": "plugin-authoring",
"preview": {
"type": "html",
"entry": "./preview/index.html"
},
"useCase": {
"query": {
"en": "Create a compact import receipt for {{plugin_name}} installed from {{source_kind}}.",
"zh-CN": "为从 {{source_kind}} 导入的 {{plugin_name}} 创建一个紧凑的插件导入回执。"
},
"exampleOutputs": [
{
"path": "./preview/index.html",
"title": "Import Receipt Preview"
}
]
},
"context": {
"skills": [
{
"path": "./SKILL.md"
}
],
"assets": [
"./assets/import-checklist.md"
],
"claudePlugins": [
{
"path": "./.claude-plugin/plugin.json"
}
],
"atoms": [
"todo-write",
"handoff"
]
},
"pipeline": {
"stages": [
{
"id": "plan",
"atoms": [
"todo-write"
]
},
{
"id": "handoff",
"atoms": [
"handoff"
]
}
]
},
"inputs": [
{
"name": "plugin_name",
"type": "string",
"required": true,
"label": "Plugin name",
"default": "community-import-smoke-test"
},
{
"name": "source_kind",
"type": "select",
"required": true,
"label": "Import source",
"options": [
"folder",
"zip",
"github",
"marketplace"
],
"default": "folder"
},
{
"name": "note",
"type": "text",
"label": "Test note",
"placeholder": "Optional detail about the import test."
}
],
"capabilities": [
"prompt:inject"
]
}
}

View file

@ -0,0 +1,128 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Community Import Smoke Test</title>
<style>
:root {
color-scheme: light;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
background: #f7f4ef;
color: #181512;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px;
}
main {
width: min(720px, 100%);
border: 1px solid #ded5ca;
border-radius: 8px;
background: #fffdf9;
box-shadow: 0 24px 80px rgb(44 30 20 / 12%);
padding: 28px;
}
p,
h1 {
margin: 0;
}
h1 {
font-size: 28px;
line-height: 1.12;
}
.eyebrow {
margin-bottom: 10px;
color: #a5543f;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.summary {
margin-top: 12px;
color: #625a51;
font-size: 15px;
line-height: 1.55;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-top: 24px;
}
.cell {
border: 1px solid #e6ddd3;
border-radius: 6px;
padding: 12px;
background: #fbf8f3;
}
.label {
color: #81776d;
font-size: 12px;
}
.value {
margin-top: 8px;
font-size: 14px;
font-weight: 700;
}
@media (max-width: 640px) {
body {
padding: 18px;
}
main {
padding: 22px;
}
.grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>
</head>
<body>
<main>
<p class="eyebrow">Open Design community plugin</p>
<h1>Import receipt generated successfully</h1>
<p class="summary">
This preview exists to make folder, zip, GitHub subpath, and marketplace
imports easy to verify in the Plugins UI.
</p>
<section class="grid" aria-label="Import checks">
<div class="cell">
<p class="label">Skill</p>
<p class="value">SKILL.md</p>
</div>
<div class="cell">
<p class="label">Sidecar</p>
<p class="value">open-design.json</p>
</div>
<div class="cell">
<p class="label">Compat</p>
<p class="value">Claude plugin</p>
</div>
<div class="cell">
<p class="label">Trust</p>
<p class="value">Restricted</p>
</div>
</section>
</main>
</body>
</html>

View file

@ -0,0 +1,10 @@
---
name: community-registry-starter
description: A small community registry starter plugin used to verify Open Design marketplace install flows.
---
# Community Registry Starter
Use this plugin as a minimal community example when validating registry search,
install, and provenance flows.

View file

@ -0,0 +1,37 @@
{
"$schema": "https://open-design.ai/schemas/plugin.v1.json",
"specVersion": "1.0.0",
"name": "community-registry-starter",
"title": "Community Registry Starter",
"version": "0.1.0",
"description": "A minimal community plugin that exercises the default registry source and install provenance path.",
"license": "MIT",
"author": {
"name": "Open Design Community",
"url": "https://open-design.ai/marketplace/community"
},
"homepage": "https://github.com/nexu-io/open-design/tree/main/plugins/community/registry-starter",
"plugin": {
"repo": "https://github.com/nexu-io/open-design/tree/main/plugins/community/registry-starter"
},
"tags": [
"community",
"registry",
"starter"
],
"od": {
"kind": "scenario",
"taskKind": "new-generation",
"mode": "prototype",
"useCase": {
"query": {
"en": "Create a compact status card that explains how a community Open Design plugin flows from Available to Installed.",
"zh-CN": "创建一个紧凑状态卡片,说明社区 Open Design 插件如何从 Available 安装到 Installed。"
}
},
"capabilities": [
"prompt:inject"
]
}
}

View file

@ -0,0 +1,61 @@
{
"$schema": "https://open-design.ai/schemas/marketplace.v1.json",
"specVersion": "1.0.0",
"name": "open-design-community",
"version": "0.1.1",
"owner": {
"name": "Open Design Community",
"url": "https://open-design.ai/marketplace/community"
},
"metadata": {
"description": "Default community plugin registry seed. Entries here are discoverable by default but remain restricted until the user installs and trusts them.",
"version": "0.1.1"
},
"plugins": [
{
"name": "community/registry-starter",
"title": "Community Registry Starter",
"version": "0.1.0",
"source": "github:nexu-io/open-design@garnet-hemisphere/plugins/community/registry-starter",
"publisher": {
"id": "open-design-community",
"github": "nexu-io",
"url": "https://github.com/nexu-io/open-design"
},
"homepage": "https://github.com/nexu-io/open-design/tree/garnet-hemisphere/plugins/community/registry-starter",
"license": "MIT",
"capabilitiesSummary": [
"prompt:inject"
],
"tags": [
"community",
"registry",
"starter"
],
"description": "A minimal community plugin that exercises the default registry source and install provenance path."
},
{
"name": "community/import-smoke-test",
"title": "Community Import Smoke Test",
"version": "0.1.0",
"source": "github:nexu-io/open-design@garnet-hemisphere/plugins/community/import-smoke-test",
"publisher": {
"id": "open-design-community",
"github": "nexu-io",
"url": "https://github.com/nexu-io/open-design"
},
"homepage": "https://github.com/nexu-io/open-design/tree/garnet-hemisphere/plugins/community/import-smoke-test",
"license": "MIT",
"capabilitiesSummary": [
"prompt:inject"
],
"tags": [
"community",
"import",
"smoke-test",
"marketplace"
],
"description": "A portable community plugin used to verify folder, zip, GitHub subpath, and marketplace import flows."
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -44,6 +44,9 @@ importers:
'@open-design/plugin-runtime':
specifier: workspace:*
version: link:../../packages/plugin-runtime
'@open-design/registry-protocol':
specifier: workspace:*
version: link:../../packages/registry-protocol
'@open-design/sidecar':
specifier: workspace:*
version: link:../../packages/sidecar
@ -374,6 +377,22 @@ importers:
specifier: ^2.1.8
version: 2.1.9(@types/node@20.19.39)(jsdom@29.1.1)(lightningcss@1.32.0)
packages/registry-protocol:
dependencies:
zod:
specifier: ^3.23.8
version: 3.25.76
devDependencies:
esbuild:
specifier: 0.27.7
version: 0.27.7
typescript:
specifier: ^5.6.3
version: 5.9.3
vitest:
specifier: ^2.1.8
version: 2.1.9(@types/node@24.12.2)(jsdom@29.1.1)
packages/sidecar:
devDependencies:
'@types/node':

View file

@ -55,6 +55,7 @@ const residualAllowedExactPaths = new Set([
"packages/contracts/esbuild.config.mjs",
"packages/platform/esbuild.config.mjs",
"packages/plugin-runtime/esbuild.config.mjs",
"packages/registry-protocol/esbuild.config.mjs",
"packages/sidecar/esbuild.config.mjs",
"packages/sidecar-proto/esbuild.config.mjs",
// Maintainer utility scripts ported from the media branch. They are

View file

@ -55,15 +55,16 @@ The repo already has the right substrate:
- `apps/daemon/src/plugins/marketplaces.ts` supports add/list/info/refresh/remove/trust and bare-name resolution through configured marketplaces.
- `apps/daemon/src/cli.ts` already exposes `od marketplace add/list/info/search/refresh/remove/trust`.
- `apps/web/src/components/MarketplaceView.tsx` and `PluginDetailView.tsx` exist for `/marketplace` and `/marketplace/:id`.
- `apps/landing-page` now has a static public `/plugins/` registry renderer and per-plugin detail routes generated from `plugins/registry/*/open-design-marketplace.json` plus bundled official manifests.
- `apps/web/src/components/PluginsView.tsx` now has the first `Installed / Available / Sources / Team` UI slice: source management is enabled and Available entries are built from cached marketplace manifests.
- `apps/daemon/src/plugins/pack.ts` can produce `.tgz` plugin archives.
- `apps/daemon/src/plugins/publish.ts` currently builds submission links for external catalogs, not a full Open Design registry publish flow.
- `apps/daemon/src/plugins/publish.ts` now builds submission links for external catalogs and the Open Design registry target. Full GitHub fork/branch/PR mutation is still future backend work.
Main gaps:
- Marketplace provenance is dropped on install. `resolvePluginInMarketplaces()` returns `marketplaceId`, `marketplaceTrust`, `pluginName`, `pluginVersion`, and `source`, but `/api/plugins/install` only forwards `source` into `installPlugin()`. The installed record therefore looks like a direct GitHub/URL install and loses `sourceMarketplaceId`.
- Trust vocabulary is inconsistent. Contracts still expose `MarketplaceTrustSchema = official | trusted | untrusted`; daemon/web already use `official | trusted | restricted`.
- Marketplace catalog entries are too thin for a registry. They lack first-class `dist`, `integrity`, `publisher`, `homepage`, `license`, `capabilitiesSummary`, `tags/distTags`, and yanked/deprecated state.
- Marketplace provenance was the first closure gap and is now plumbed through install, upgrade, installed records, and applied snapshots. Remaining work is exact version/tag resolution and lockfiles.
- Trust vocabulary has been unified to `official | trusted | restricted`; legacy `untrusted` marketplace rows normalize to `restricted`.
- Marketplace catalog entries now carry registry-grade optional fields: `versions`, `dist`, `integrity`, `manifestDigest`, `publisher`, `homepage`, `license`, `capabilitiesSummary`, `distTags`, deprecated state, and yanking metadata.
- UI discovery has a first slice but still needs backend closure. The Plugins page can show Available entries from cached manifests and manage Sources, but large-catalog browsing, provenance-aware `--from`, and richer detail pages are still pending.
- Private marketplace support is public-HTTPS-only. There is no `gh`-backed private GitHub/GitHub Enterprise source flow, refresh policy, allowlist, TLS/private-network guidance, or offline cache mode.
- npm-grade update semantics are not implemented. There is no version range resolver, dist-tag support, lockfile/update policy, publisher verification, or archive checksum enforcement.
@ -117,12 +118,12 @@ Installed plugin
The UI layers are not additional backends; they are different views over this same lifecycle:
- **Home / Official starters** is a usage shelf, not a registry. It should show a curated subset of already-installed bundled/official workflows so a user can immediately click `Use`. Product copy should say `Official starters` or `Official installed`, not imply this is the registry itself.
- **Home / Official starters** is a usage shelf, not a registry. It should show a curated subset of already-installed bundled/official workflows so a user can immediately click `Use`. Bundled official plugins are the preinstalled cache of the `official` registry source, not a separate distribution model. Product copy should say `Official starters` or `Official installed`, not imply this is the registry itself.
- **Plugins / Installed** is the complete local inventory: bundled official plugins, user-created plugins, local imports, GitHub/URL installs, and marketplace-installed plugins.
- **Plugins / Available** is the discovery layer: registry entries from configured Sources that are not installed yet or have a newer version available.
- **Plugins / Sources** is the registry management layer: official, community, self-hosted, and enterprise catalog sources; trust tier; refresh; removal; auth/cache status later.
- **Plugins / Team** is the future enterprise governance layer: private catalogs, organization policy, allowlists, review, audit, and refresh policy.
- **open-design.ai/marketplace** is the public presentation of the official registry. It is equivalent to a polished static renderer over the official source, not a separate source of truth.
- **open-design.ai/plugins** is the public presentation of the official and community registry sources. It is equivalent to a polished static renderer over repo-owned catalog data, not a separate source of truth. `open-design.ai/marketplace` can remain an alias later if needed.
- **`od` CLI** remains the canonical client. Every UI action must map to a CLI operation or daemon API that the CLI can also drive.
- **Open Design GitHub registry repo** is the v1 storage backend. It can later be swapped for a database backend without changing user-facing nouns.
@ -136,14 +137,28 @@ User adds registry source
-> Installed becomes part of agent context/runtime consumption
Open Design packaged runtime
-> bundled official plugins ship with the app
-> startup records them as Installed/bundled
-> official registry entries are bundled as a preinstall cache
-> startup records them as Installed/bundled with sourceMarketplaceId=official
-> Home / Official starters exposes a curated quick-use shelf
-> agent can consume them immediately
Default community registry
-> community source is configured by default
-> Available shows restricted community entries
-> user explicitly installs one
-> plugin is copied to ~/.open-design/plugins/<plugin-id>
-> Installed becomes part of agent context/runtime consumption
```
`Available` entries are supply candidates, not runnable capabilities. The agent should consume the installed set: bundled official plugins, user-created plugins, direct GitHub/URL/local installs, and marketplace-installed plugins. A future "Use from Available" shortcut can auto-install first, but it must still produce an installed record before the agent runs it.
User-created and user-installed plugins live in the user-state plugin root,
`~/.open-design/plugins/<plugin-id>` by default. The daemon reloads those
installed records and folders on later boots. Runtime-bundled official plugins
stay inside the app/repo image and are re-registered on boot as official-source
preinstalls; updating them can later happen by refreshing/installing from the
official registry source instead of waiting for an app release.
The production-side loop is the mirror image:
```text
@ -161,15 +176,16 @@ The `Create plugin` product entry should therefore start an agent workflow, not
High-level architecture relationship:
```text
open-design.ai/marketplace
open-design.ai/plugins
public registry pages and docs
|
v
+--------------------------------------------------+
| Open Design GitHub registry repo |
| |
| community/official/**/open-design.json |
| community/<vendor>/<plugin-name>/open-design.json|
| plugins/registry/official/open-design-marketplace.json |
| plugins/registry/community/open-design-marketplace.json |
| plugins/community/<vendor>/<plugin-name>/open-design.json |
| generated open-design-marketplace.json |
+-----------------------------+--------------------+
|
@ -242,10 +258,10 @@ open-design-plugin-registry/
Current repo-friendly data placement:
- First-party runtime plugins that ship inside OD still live under `plugins/_official/**` and are installed as `bundled`.
- Registry presentation data can start as static community catalog data. Use a `community/official` slice in the registry repo, or mirror that shape in this repo until the registry repo exists.
- First-party runtime plugins that ship inside OD still live under `plugins/_official/**` and are installed as `bundled`, but they carry official marketplace provenance so product/audit treat them as preinstalled official registry entries.
- Registry presentation data can start as static catalog data under `plugins/registry/official` and `plugins/registry/community`, or mirror that shape in the registry repo until it exists.
- The main site should render official plugins from generated catalog artifacts, not by importing daemon internals or walking `plugins/_official` directly.
- Community submissions can land beside `community/official` later as `community/trusted` or `community/restricted`, with trust tier encoded per entry.
- Community submissions can land as plugin source folders under `plugins/community/<vendor-or-plugin>` and be referenced by `plugins/registry/community/open-design-marketplace.json`; trust tier stays encoded per source/entry.
Namespace and source policy:
@ -281,7 +297,7 @@ Minimum entry shape:
"github": "open-design"
},
"sourceRepository": "https://github.com/open-design/plugins/tree/main/make-a-deck",
"homepage": "https://open-design.ai/marketplace/open-design/make-a-deck",
"homepage": "https://open-design.ai/plugins/open-design/make-a-deck/",
"license": "MIT",
"capabilitiesSummary": ["prompt:inject", "fs:read"],
"tags": ["deck", "presentation", "investor"],
@ -366,7 +382,7 @@ Enterprise self-hosting model:
Commercial invariant:
- v1 data can be static files in `community/official`, but contracts must not assume "registry equals GitHub repo".
- v1 data can be static files in `plugins/registry/official`, but contracts must not assume "registry equals GitHub repo".
- UI must ask the daemon for registry/search/resolve/publish data; it must never assume the catalog is a local directory.
- CLI commands must not expose GitHub-specific nouns except where the source explicitly is GitHub. `od plugin publish --to open-design` may use `gh` internally, but the command contract should survive a later database backend.
- Trust, provenance, versioning, integrity, and audit fields are mandatory because those become enterprise policy inputs later.
@ -412,8 +428,8 @@ Keep existing `od marketplace` and `od plugin` naming. Avoid adding a second `re
### Plugin Author Commands
- [ ] `od plugin login [--host <github-host>]`
- [ ] `od plugin whoami [--host <github-host>] [--json]`
- [x] `od plugin login [--host <github-host>]`
- [x] `od plugin whoami [--host <github-host>] [--json]`
- [x] `od plugin scaffold`
- [x] `od plugin validate`
- [x] `od plugin pack`
@ -551,15 +567,15 @@ Private marketplace support in v1 should reuse GitHub auth instead of introducin
Goal: make the current federated marketplace implementation trustworthy and auditable.
- [ ] Change `MarketplaceTrustSchema` to `official | trusted | restricted`.
- [ ] Migrate/accept old `untrusted` rows as `restricted` during read or migration.
- [ ] Extend install options with marketplace provenance fields.
- [ ] When `/api/plugins/install` resolves a bare marketplace name, pass the full `ResolvedPluginEntry` into `installPlugin()`.
- [ ] Persist `sourceMarketplaceId`, entry name/version, marketplace trust, and resolved source/ref on `installed_plugins`.
- [ ] Map marketplace trust into installed plugin default trust.
- [ ] Extend `AppliedPluginSnapshot` with marketplace entry name/version and resolved source metadata.
- [ ] Add tests: marketplace add -> install by name -> installed record contains source marketplace id and inherited trust.
- [ ] Add tests: restricted marketplace install stays restricted even when transport is GitHub.
- [x] Change `MarketplaceTrustSchema` to `official | trusted | restricted`.
- [x] Migrate/accept old `untrusted` rows as `restricted` during read or migration.
- [x] Extend install options with marketplace provenance fields.
- [x] When `/api/plugins/install` resolves a bare marketplace name, pass the full `ResolvedPluginEntry` into `installPlugin()`.
- [x] Persist `sourceMarketplaceId`, entry name/version, marketplace trust, and resolved source/ref on `installed_plugins`.
- [x] Map marketplace trust into installed plugin default trust.
- [x] Extend `AppliedPluginSnapshot` with marketplace entry name/version and resolved source metadata.
- [x] Add tests: marketplace add -> install by name -> installed record contains source marketplace id and inherited trust.
- [x] Add tests: restricted marketplace install stays restricted even when transport is GitHub.
### P1: Registry Entry And Version Semantics
@ -567,15 +583,15 @@ Goal: move from "catalog index" to "registry entry".
- [ ] Update plugin manifest schema to allow published namespaced ids `vendor/plugin-name` while keeping flat ids readable for legacy local/bundled plugins.
- [ ] Add formal `plugin.repo` schema field to `open-design.json` and require it for registry publish.
- [ ] Extend marketplace entry contract and JSON schema with `dist`, `integrity`, `manifestDigest`, `publisher`, `homepage`, `license`, `capabilitiesSummary`, `distTags`, `deprecated`, and `yanked`.
- [ ] Keep `.passthrough()` for community extensions.
- [ ] Add `od marketplace plugins <id>` with pagination/search/filter.
- [ ] Add `od plugin install <name>@<version-or-tag>`.
- [ ] Add resolver support for exact version and dist-tag.
- [ ] Add initial `od.lock` or `.od/plugins-lock.json` shape with name, version, source, marketplace id, resolved ref, manifest digest, archive integrity.
- [x] Extend marketplace entry contract and JSON schema with `versions`, `dist`, `integrity`, `manifestDigest`, `publisher`, `homepage`, `license`, `capabilitiesSummary`, `distTags`, `deprecated`, and `yanked`.
- [x] Keep `.passthrough()` for community extensions.
- [x] Add `od marketplace plugins <id>` with pagination/search/filter.
- [x] Add `od plugin install <name>@<version-or-tag>`.
- [x] Add resolver support for exact version, dist-tag, and conservative `^`/`~` ranges.
- [x] Add initial `.od/od-plugin-lock.json` shape with name, version, source, marketplace id, resolved ref, manifest digest, archive integrity.
- [ ] Add `od plugin lock verify`.
- [ ] Add `od plugin outdated`.
- [ ] Add yanking metadata and resolver behavior: yanked versions are visible for audit, refused for new resolution, and allowed only for exact locked replay with warning.
- [x] Add yanking metadata and resolver behavior: yanked versions are visible for audit and refused for new resolution. Exact locked replay warning remains a route-level follow-up once lock verify lands.
### P2: GitHub-Backed Publish Flow
@ -583,7 +599,7 @@ Goal: make Open Design contributions feel like npm publish, while actually openi
- [ ] Define official registry repo layout and generated index build step.
- [ ] Make `gh` an explicit `od` CLI dependency and installer prerequisite/bootstrap step.
- [ ] Add `od plugin login` and `od plugin whoami` as wrappers over `gh auth login/status` and `gh api user`.
- [x] Add `od plugin login` and `od plugin whoami` as wrappers over `gh auth login/status` and `gh api user`.
- [ ] Add `od plugin publish --to open-design --dry-run --json`.
- [ ] Use `gh auth status`, `gh api`, `gh repo fork`, and `gh pr create` through a narrow `GhClient` adapter.
- [ ] Generate a registry entry from local plugin metadata, `plugin.repo`, current ref, digest, publisher, license, and capability summary.
@ -592,7 +608,7 @@ Goal: make Open Design contributions feel like npm publish, while actually openi
- [ ] Run `od plugin validate`, `pack`, `doctor`, and integrity calculation before PR creation.
- [ ] Add PR template with source, version, capability risk, preview, screenshots, and validation output.
- [ ] Add CI in registry repo: schema validate, source fetch, plugin manifest parse, checksum verify, preview smoke, blocked source scan.
- [ ] Add `od plugin publish --to marketplace-json --catalog <path>` for self-hosted static catalogs.
- [x] Add `od plugin publish --to marketplace-json --catalog <path>` for self-hosted static catalogs.
### P3: Product UI And Public Site
@ -601,53 +617,54 @@ Goal: upgrade from "installed plugin gallery" to "multi-source plugin registry".
- [x] Replace the Plugins page tabs with `Installed / Available / Sources / Team`.
- [x] Enable source management in-app: add URL, refresh, remove, and trust tier using the existing `/api/marketplaces` endpoints.
- [x] Add an `Available` view from configured marketplace manifests. Current implementation reads cached manifests returned by `/api/marketplaces`; a follow-up should move this to a typed paginated `/api/marketplaces/:id/plugins` response for large catalogs.
- [x] Add install/use/upgrade card states for available entries. Current install uses the existing bare-name `od plugin install <name>` path; provenance-aware `--from <marketplace-id>` remains a P0/P1 backend task.
- [ ] Rename the Home page official shelf copy to `Official starters` or `Official installed`, and add a lightweight `Browse registry` path to `/plugins` so Home stays a fast-use surface while `/plugins` remains the registry console.
- [ ] Make `Create plugin` launch an agent-assisted authoring flow backed by `od plugin scaffold/validate/pack/publish`, including local install/run validation before publish and `gh` login/whoami checks before opening a registry PR.
- [x] Add install/use/upgrade card states for available entries. Current install uses the existing bare-name `od plugin install <name>` path and now preserves provenance; explicit `--from <marketplace-id>` remains a P1 follow-up.
- [x] Rename the Home page official shelf copy to `Official starters` or `Official installed`, and add a lightweight `Browse registry` path to `/plugins` so Home stays a fast-use surface while `/plugins` remains the registry console.
- [x] Make `Create plugin` launch an agent-assisted authoring flow backed by `od plugin scaffold/validate/pack/publish`, including local install/run validation before publish and `gh` login/whoami checks before opening a registry PR. Current slice updates the agent prompt and CLI wrapper; full GitHub PR mutation remains in P2.
- [x] Add public `/plugins/` route on `apps/landing-page` for open-design.ai: searchable official/community registry listing, static plugin detail pages, canonical/OG/Twitter metadata, JSON-LD item/detail data, and homepage/header entry points.
- [ ] Add source filters: Official, Community, My plugins, Team, specific source.
- [ ] Add detail provenance, publisher, version, integrity, command, and risk sections.
- [x] Add detail provenance, publisher, version, integrity, command, and risk sections to the public website detail route; in-app drawer polish remains tracked separately.
- [ ] Add GitHub host/auth status, cached status, and refresh policy to the source manager.
- [ ] Add public `/marketplace` page on main site backed by generated official catalog artifacts.
- [ ] Add public plugin detail pages with install commands and `od://` deep links.
- [x] Add public plugin detail `od://` deep links and static search JSON. README rendering and preview galleries remain content-quality follow-ups.
- [ ] Decide whether `/marketplace` should redirect to `/plugins/` or remain an alias for compatibility.
### P4: Private, Enterprise, And Offline
Goal: make third-party/self-hosted registries first-class while staying compatible with a future database backend.
- [ ] Add private GitHub/GitHub Enterprise marketplace support through `gh` hosts.
- [ ] Keep source management behind `PluginRegistryBackend`, not GitHub-specific API calls.
- [x] Add private GitHub/GitHub Enterprise marketplace auth entry through `od marketplace login <id|url> --host <host>`, delegated to `gh`.
- [x] Keep source management behind `RegistryBackend`, not GitHub-specific API calls.
- [ ] Add enterprise allowlist policy: source ids, publishers, GitHub orgs, capabilities.
- [ ] Add refresh policy and last-known-good cache.
- [ ] Add offline install from cache when enabled.
- [ ] Add audit log/events for source and install decisions.
- [ ] Add Team page for private catalog status, trust defaults, org policy, and audit.
- [ ] Document static hosting options: GitHub Pages, private GitHub repos, GitHub Enterprise, S3/R2 public HTTPS, internal HTTPS, and private network caveats.
- [x] Document static hosting options: GitHub Pages, private GitHub repos, GitHub Enterprise, S3/R2 public HTTPS, internal HTTPS, and private network caveats.
### P5: Database Backend And Commercial ToB
Goal: make the registry deployable as an enterprise service with real database state.
- [ ] Define database-backed registry schema for orgs, sources, packages, versions, artifacts, publishers, reviews, policies, installs, and audit events.
- [ ] Add `DatabaseRegistryBackend` behind the same resolve/search/publish/doctor interface.
- [x] Add `DatabaseRegistryBackend` behind the same resolve/search/publish/doctor interface.
- [ ] Add object-storage abstraction for plugin archives and preview assets.
- [ ] Add org-scoped auth/identity boundary; hosted can use first-party auth, self-host can use enterprise IdP integration later.
- [ ] Add policy engine hooks: source allowlist, capability denylist, required review, yanked/deprecated enforcement, approval exceptions.
- [ ] Add admin APIs and UI for Team/Enterprise registry governance.
- [ ] Add migration/import from static `open-design-marketplace.json` and GitHub registry repo into database rows.
- [ ] Add export back to `open-design-marketplace.json` so enterprises can mirror or air-gap catalogs.
- [ ] Add tests proving CLI/UI behavior is identical for GitHub/static and database backends.
- [x] Add backend parity tests for static/GitHub/database list/search/resolve/publish. Full CLI/UI parity against DB remains an enterprise API follow-up.
### P6: npm-Grade Hardening
Goal: make updates reproducible and safe enough for CI/enterprise use.
- [ ] Add range resolution only after exact/tag resolution is solid.
- [x] Add range resolution only after exact/tag resolution is solid.
- [ ] Add update policies: `pinned`, `patch`, `minor`, `latest`.
- [ ] Add yanked/deprecated handling in resolver and UI.
- [ ] Add publisher verification against GitHub org/user ownership.
- [ ] Add signed provenance hooks later, but do not block v1 on PKI.
- [ ] Add `od marketplace doctor` checks: every entry downloads, manifest parses, digest matches, required fields present, capabilities declared, preview safe.
- [ ] Add CI smoke: add marketplace -> search -> install by name -> apply -> run -> snapshot provenance traceable.
- [x] Add yanked/deprecated handling in resolver; UI surfacing remains part of detail drawer polish.
- [x] Add publisher verification hooks against GitHub org/user metadata.
- [x] Add signed provenance schema hooks, without blocking v1 on PKI.
- [x] Add `od marketplace doctor` checks: entry naming/source/yank/capability/license/integrity basics, with strict mode for warnings.
- [x] Add daemon smoke coverage: add marketplace -> resolve/search -> install by name -> installed row and lockfile preserve provenance.
## Suggested First PR Split
@ -693,6 +710,7 @@ Additional validation by area:
- UI changes: add web component/state tests; use Browser/Playwright screenshots for larger visual route changes.
- Publish/GitHub changes: tests must run without real network by injecting a fake `GhClient` or dry-run backend.
- Enterprise/private source changes: tests must assert GitHub credentials stay in `gh` and are never serialized into marketplace manifests or daemon SQLite.
- Registry product evaluation cases are tracked in `docs/testing/plugin-registry-eval-cases.md`; keep it updated whenever Sources, Available, Installed, GitHub publish, or enterprise backend behavior changes.
## Open Questions

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