feat(daemon, web): enhance plugin handling and UI integration

- Introduced a new plugin upload mechanism with file size limits and memory storage, allowing users to upload plugins directly.
- Implemented fallback logic for plugin application, ensuring projects can be created without explicit plugin requests.
- Enhanced the UI to support plugin selection and integration, including a new `PluginsView` component for managing plugins.
- Updated various components to utilize localized text for plugin queries, improving user experience across different languages.
- Added tests for new plugin functionalities and local skill loading, ensuring reliability and correctness.

This update significantly improves the plugin management experience, providing users with better tools for plugin integration and interaction.
This commit is contained in:
pftom 2026-05-12 20:42:40 +08:00
parent 89bd42c3d4
commit 443aea72c5
390 changed files with 4426 additions and 474 deletions

View file

@ -21,6 +21,7 @@ import {
} from '@open-design/plugin-runtime';
import {
renderPluginBlock,
resolveLocalizedText,
type AppliedPluginSnapshot,
type ApplyResult,
type InstalledPluginRecord,
@ -64,6 +65,9 @@ export interface ApplyInput {
// `od.context.designSystem.primary: true` without a concrete ref get
// bound to this id at apply time.
activeProjectDesignSystem?: { id: string; title?: string } | undefined;
// UI locale used to resolve localized manifest strings. Snapshots store
// the resolved string so historical runs never change when translations do.
locale?: string | undefined;
// Sync probe over the connector catalog + status maps. When supplied,
// apply resolves `od.connectors.*` against the live catalog and
// auto-derives an `oauth-prompt` GenUI surface for any not-yet-connected
@ -154,9 +158,7 @@ export function applyPlugin(input: ApplyInput): ApplyComputed {
projectMetadata.craftRequires = manifest.od!.context!.craft!.slice();
}
const queryText = typeof manifest.od?.useCase?.query === 'string'
? manifest.od.useCase.query
: '';
const queryText = resolveLocalizedText(manifest.od?.useCase?.query, input.locale);
const appliedAt = Date.now();
const snapshot: AppliedPluginSnapshot = {
@ -258,10 +260,48 @@ function buildAssetRefs(manifest: PluginManifest): PluginAssetRef[] {
return out;
}
// Pick a global skill id from od.context.skills[]. Two ref shapes are
// accepted:
//
// - `{ ref: 'skill-id' }` — registry id; returned as-is.
// - `{ path: 'subdir/SKILL.md' }` — plugin-local file; returned as
// undefined so the project record never stores a non-existent skill
// id like 'SKILL.md'. Plugin-local SKILL.md bodies are sourced
// directly by the daemon at prompt-compose time from the installed
// plugin's fsPath (see server.ts) — they do NOT roam into the
// global skills registry.
function pickFirstSkillId(manifest: PluginManifest): string | undefined {
for (const ref of manifest.od?.context?.skills ?? []) {
const id = (ref?.ref ?? ref?.path ?? '').trim();
if (id) return id.startsWith('./') ? id.slice(2) : id;
if (typeof ref?.ref === 'string' && ref.ref.trim().length > 0) {
return ref.ref.trim();
}
const rawPath = typeof ref?.path === 'string' ? ref.path.trim() : '';
if (!rawPath) continue;
if (isPluginLocalPath(rawPath)) continue;
return rawPath;
}
return undefined;
}
function isPluginLocalPath(value: string): boolean {
return (
value.startsWith('./') ||
value.startsWith('../') ||
value.includes('/')
);
}
// Return the first plugin-local skill ref path (relative to plugin
// fsPath), if any. Used by the daemon prompt composer to read a
// plugin's SKILL.md body without re-walking the manifest. Mirrors the
// detection inside `pickFirstSkillId` so the two stay in lockstep.
export function pickFirstLocalSkillPath(manifest: PluginManifest): string | undefined {
for (const ref of manifest.od?.context?.skills ?? []) {
if (typeof ref?.ref === 'string' && ref.ref.trim().length > 0) continue;
const rawPath = typeof ref?.path === 'string' ? ref.path.trim() : '';
if (!rawPath) continue;
if (!isPluginLocalPath(rawPath)) continue;
return rawPath;
}
return undefined;
}

View file

@ -0,0 +1,75 @@
// Plugin-local SKILL.md loader (Stage A of plugin-driven-flow-plan).
//
// Plugins that declare `od.context.skills[{ path: './SKILL.md' }]` ship
// their own skill body inside their plugin folder. Those files never
// register against the global skills registry, so the
// `composeSystemPrompt` skill slot would otherwise be empty.
//
// This module is the lone reader of plugin-local SKILL.md files. It
// stays separate from `apply.ts` because apply.ts is intentionally pure
// (no filesystem reads) — the daemon calls this loader during prompt
// composition, not during snapshot apply.
//
// The returned record mirrors the shape `composeDaemonSystemPrompt`
// already consumes for global skills (`body`, `name`, `dir`) so the
// override is a drop-in.
import path from 'node:path';
import { promises as fsp } from 'node:fs';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { pickFirstLocalSkillPath } from './apply.js';
export interface PluginLocalSkill {
body: string;
name: string;
// Absolute directory containing the SKILL.md — used by
// `stageActiveSkill` to copy companion files into the project cwd.
dir: string;
// Relative path inside the plugin folder, kept for debugging /
// logging. Always normalised (no leading './').
relpath: string;
}
export async function loadPluginLocalSkill(
plugin: InstalledPluginRecord,
): Promise<PluginLocalSkill | null> {
const manifest = plugin.manifest;
const relpath = pickFirstLocalSkillPath(manifest);
if (!relpath) return null;
const safeRel = stripLeadingDotSlash(relpath);
// Guard against path traversal — the manifest is trusted but we still
// refuse `..` escapes so a bad plugin author can't reach outside its
// own fsPath.
if (safeRel.split('/').some((segment) => segment === '..')) return null;
const abs = path.join(plugin.fsPath, safeRel);
let raw: string;
try {
raw = await fsp.readFile(abs, 'utf8');
} catch {
return null;
}
const body = stripFrontmatter(raw).trim();
if (!body) return null;
const name = (manifest.title ?? manifest.name ?? plugin.id).toString();
return {
body,
name,
dir: path.dirname(abs),
relpath: safeRel,
};
}
function stripLeadingDotSlash(value: string): string {
return value.startsWith('./') ? value.slice(2) : value;
}
// Mirrors the loader inside `atom-bodies.ts`. Kept duplicated here on
// purpose: atom-bodies is the lone reader for atom SKILL.md, and we do
// not want to grow a cross-file import surface for one regex.
function stripFrontmatter(raw: string): string {
if (!raw.startsWith('---')) return raw;
const closeIdx = raw.indexOf('\n---', 3);
if (closeIdx === -1) return raw;
const after = raw.slice(closeIdx + 4);
return after.replace(/^\r?\n/, '');
}

View file

@ -128,7 +128,8 @@ function pickPluginFields(body: Record<string, unknown> | null | undefined) {
? (body.grantCaps as unknown[])
.filter((c): c is string => typeof c === 'string')
: [];
return { pluginId, snapshotId, pluginInputs, grantCaps };
const locale = typeof body.locale === 'string' ? body.locale : undefined;
return { pluginId, snapshotId, pluginInputs, grantCaps, locale };
}
export function resolvePluginSnapshot(input: ResolveSnapshotInput): ResolveSnapshotResult {
@ -213,6 +214,7 @@ export function resolvePluginSnapshot(input: ResolveSnapshotInput): ResolveSnaps
registry: input.registry,
activeProjectDesignSystem: input.activeProjectDesignSystem,
connectorProbe: input.connectorProbe,
locale: fields.locale,
});
} catch (err) {
if (err instanceof MissingInputError) {

View file

@ -2,6 +2,7 @@
import type { DesktopExportPdfInput, DesktopExportPdfResult } from '@open-design/sidecar-proto';
import express from 'express';
import multer from 'multer';
import JSZip from 'jszip';
import { execFile, spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
@ -10,6 +11,7 @@ import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
import net from 'node:net';
import { defaultScenarioPluginIdForKind } from '@open-design/contracts';
import {
composeSystemPrompt,
renderCodexImagegenOverride,
@ -1574,6 +1576,16 @@ const importUpload = multer({
limits: { fileSize: 100 * 1024 * 1024 },
});
const PLUGIN_UPLOAD_MAX_BYTES = 50 * 1024 * 1024;
const pluginUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: PLUGIN_UPLOAD_MAX_BYTES,
files: 500,
fieldSize: 2 * 1024 * 1024,
},
});
// Project-scoped multi-file upload. Lands files directly in the project
// folder (flat — same shape FileWorkspace expects), so the composer's
// pasted/dropped/picked images become referenceable filenames the agent
@ -2741,23 +2753,62 @@ export async function startServer({
// failures land on a 4xx here; the project is left in place because
// it is already inserted (the snapshot resolver runs after — re-
// applying via /api/plugins/:id/apply is the recovery path).
//
// Stage A of plugin-driven-flow-plan: when neither field is set
// we fall back to the bundled scenario plugin for the project's
// kind, so a "naked" Home query still binds a snapshot instead of
// dropping into the legacy plugin-less agent path. The fallback
// is best-effort — if the bundled scenario is not installed (for
// example a stripped-down packaged build) we silently skip and
// the project is created without a snapshot, matching the legacy
// behaviour.
let projectAppliedSnapshot = null;
if (req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId)) {
const explicitPlugin =
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
let resolveBody = req.body;
if (!explicitPlugin && metadata && typeof metadata === 'object') {
const fallbackPluginId = defaultScenarioPluginIdForKind(metadata.kind);
if (fallbackPluginId) {
const fallbackPlugin = getInstalledPlugin(db, fallbackPluginId);
if (fallbackPlugin) {
resolveBody = { ...req.body, pluginId: fallbackPluginId };
}
}
}
if (resolveBody && (resolveBody.pluginId || resolveBody.appliedPluginSnapshotId)) {
try {
const registry = await loadPluginRegistryView();
const resolved = resolvePluginSnapshot({
db,
body: req.body,
body: resolveBody,
projectId: id,
conversationId: cid,
registry,
});
if (resolved && !resolved.ok) {
return res.status(resolved.status).json(resolved.body);
// Fallback bindings must never block project creation. The
// user did not explicitly request this plugin, so a
// capability / missing-input failure here means "skip the
// fallback and let the project exist without a snapshot",
// not "fail the whole create".
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for project ${id}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
}
} else {
projectAppliedSnapshot = resolved;
}
projectAppliedSnapshot = resolved;
} catch (err) {
return sendApiError(res, 500, 'PLUGIN_APPLY_FAILED', String(err));
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback errored for project ${id}: ${err?.message ?? err}`,
);
} else {
return sendApiError(res, 500, 'PLUGIN_APPLY_FAILED', String(err));
}
}
}
/** @type {import('@open-design/contracts').CreateProjectResponse} */
@ -3450,6 +3501,157 @@ export async function startServer({
}
});
async function finishUploadedPluginInstall(stagedFolder, source) {
const warnings = [];
const log = [];
let plugin = null;
let message = 'Install finished.';
try {
const pluginRoot = await findUploadedPluginRoot(stagedFolder);
for await (const ev of installFromLocalFolder(db, {
source,
_stagedFolder: pluginRoot,
_stagedSourceKind: 'user',
})) {
if (ev.message) log.push(ev.message);
if (Array.isArray(ev.warnings)) warnings.splice(0, warnings.length, ...ev.warnings);
if (ev.kind === 'success') {
plugin = ev.plugin;
message = `Installed ${ev.plugin.title}.`;
break;
}
if (ev.kind === 'error') {
message = ev.message;
break;
}
}
return { ok: Boolean(plugin), plugin, warnings, message, log };
} finally {
await fs.promises.rm(stagedFolder, { recursive: true, force: true }).catch(() => undefined);
}
}
async function findUploadedPluginRoot(stagedFolder) {
if (await folderLooksLikePlugin(stagedFolder)) return stagedFolder;
const entries = await fs.promises.readdir(stagedFolder, { withFileTypes: true });
const dirs = entries.filter((entry) => entry.isDirectory());
const files = entries.filter((entry) => entry.isFile());
if (files.length === 0 && dirs.length === 1) {
const nested = path.join(stagedFolder, dirs[0].name);
if (await folderLooksLikePlugin(nested)) return nested;
}
return stagedFolder;
}
async function folderLooksLikePlugin(folder) {
const names = ['open-design.json', 'SKILL.md', path.join('.claude-plugin', 'plugin.json')];
for (const name of names) {
if (fs.existsSync(path.join(folder, name))) return true;
}
return false;
}
function safeUploadRelativePath(input) {
const value = String(input || '').replace(/\\/g, '/');
if (!value || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:\//.test(value)) {
throw new Error('invalid upload path');
}
const parts = value.split('/').filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..')) {
throw new Error(`unsafe upload path: ${value}`);
}
return parts.join(path.sep);
}
async function extractPluginZipToFolder(buffer, stagedFolder) {
if (buffer.length > PLUGIN_UPLOAD_MAX_BYTES) {
throw new Error('zip file too large');
}
const zip = await JSZip.loadAsync(buffer);
let totalBytes = 0;
const entries = Object.values(zip.files);
if (entries.length === 0) throw new Error('zip contains no files');
for (const entry of entries) {
if (entry.dir) continue;
const rel = safeUploadRelativePath(entry.name);
const unixMode = typeof entry.unixPermissions === 'number' ? entry.unixPermissions : 0;
if ((unixMode & 0o170000) === 0o120000) {
throw new Error(`zip entry is a symbolic link: ${entry.name}`);
}
const content = await entry.async('nodebuffer');
totalBytes += content.length;
if (totalBytes > PLUGIN_UPLOAD_MAX_BYTES) {
throw new Error('zip extracted size exceeds 50 MiB');
}
const dest = path.join(stagedFolder, rel);
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.writeFile(dest, content);
}
}
app.post('/api/plugins/upload-zip', (req, res) => {
pluginUpload.single('file')(req, res, async (err) => {
if (err) return sendMulterError(res, err);
try {
const file = req.file;
if (!file || !file.buffer) {
return res.status(400).json({ error: 'file is required' });
}
const stagedFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'od-plugin-zip-'));
await extractPluginZipToFolder(file.buffer, stagedFolder);
const result = await finishUploadedPluginInstall(
stagedFolder,
`upload:zip:${decodeMultipartFilename(file.originalname || 'plugin.zip')}`,
);
res.status(result.ok ? 200 : 400).json(result);
} catch (uploadErr) {
res.status(400).json({
ok: false,
warnings: [],
message: String(uploadErr?.message || uploadErr),
log: [],
});
}
});
});
app.post('/api/plugins/upload-folder', (req, res) => {
pluginUpload.array('files', 500)(req, res, async (err) => {
if (err) return sendMulterError(res, err);
const stagedFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'od-plugin-folder-'));
try {
const files = Array.isArray(req.files) ? req.files : [];
if (files.length === 0) {
return res.status(400).json({ error: 'files are required' });
}
const rawPaths = req.body?.paths;
const paths = Array.isArray(rawPaths) ? rawPaths : rawPaths ? [rawPaths] : [];
let totalBytes = 0;
for (let i = 0; i < files.length; i += 1) {
const file = files[i];
totalBytes += file.buffer.length;
if (totalBytes > PLUGIN_UPLOAD_MAX_BYTES) {
throw new Error('folder upload exceeds 50 MiB');
}
const rel = safeUploadRelativePath(paths[i] || file.originalname);
const dest = path.join(stagedFolder, rel);
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await fs.promises.writeFile(dest, file.buffer);
}
const result = await finishUploadedPluginInstall(stagedFolder, 'upload:folder');
res.status(result.ok ? 200 : 400).json(result);
} catch (uploadErr) {
await fs.promises.rm(stagedFolder, { recursive: true, force: true }).catch(() => undefined);
res.status(400).json({
ok: false,
warnings: [],
message: String(uploadErr?.message || uploadErr),
log: [],
});
}
});
});
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 : '';
@ -3642,9 +3844,10 @@ export async function startServer({
const grantCaps = Array.isArray(body.grantCaps)
? body.grantCaps.filter((c) => typeof c === 'string')
: [];
const locale = typeof body.locale === 'string' ? body.locale : undefined;
const registry = await loadPluginRegistryView();
const computed = applyPlugin({ plugin, inputs, registry });
const computed = applyPlugin({ plugin, inputs, registry, locale });
// Plan §3.B2 — apply-time grants are merged into the snapshot's
// capabilitiesGranted so the §9 capability gate sees them, but
// they are NOT written back to installed_plugins.capabilities_granted.
@ -6237,6 +6440,37 @@ export async function startServer({
}
}
// Stage A of plugin-driven-flow-plan: when the run is bound to a
// plugin snapshot, prefer the plugin's local SKILL.md (declared via
// `od.context.skills[{ path: './SKILL.md' }]`) over the global
// skill. Without this override the agent loses the plugin's
// template / token / layout rules and falls back to generic prompt
// behaviour even though the user explicitly applied the plugin.
if (
typeof appliedPluginSnapshotId === 'string'
&& appliedPluginSnapshotId.length > 0
) {
try {
const snap = getSnapshot(db, appliedPluginSnapshotId);
if (snap?.pluginId) {
const plugin = getInstalledPlugin(db, snap.pluginId);
if (plugin) {
const { loadPluginLocalSkill } = await import('./plugins/local-skill.js');
const local = await loadPluginLocalSkill(plugin);
if (local) {
skillBody = local.body;
skillName = local.name;
activeSkillDir = local.dir;
}
}
}
} catch (err) {
console.warn(
`[plugins] pluginSkillBody load failed: ${err?.message ?? err}`,
);
}
}
let craftBody;
let craftSections;
if (skillCraftRequires.length > 0) {
@ -7579,6 +7813,14 @@ export async function startServer({
// for missing-input / capability / not-found / stale, or an ok result
// whose `snapshotId` is pinned onto the run object so downstream
// code (system prompt block, tool tokens, replay) can reach it.
//
// Stage A of plugin-driven-flow-plan: when neither the body nor the
// project carries plugin info we fall back to the bundled scenario
// plugin for the project's `metadata.kind` so direct callers (CLI /
// SDK / agent-headless runs) get the same auto-binding the web
// create flow already produces. The fallback is silent — a bundled
// scenario that is not installed leaves the run plugin-less, which
// matches the legacy path.
let resolvedSnapshot = null;
if (typeof req.body?.projectId === 'string' && req.body.projectId) {
let registryView;
@ -7587,9 +7829,26 @@ export async function startServer({
} catch (err) {
return res.status(500).json({ error: String(err) });
}
const explicitPlugin =
req.body && (req.body.pluginId || req.body.appliedPluginSnapshotId);
let runResolveBody = req.body;
if (!explicitPlugin) {
const projectRow = getProject(db, req.body.projectId);
const hasPin =
typeof projectRow?.appliedPluginSnapshotId === 'string'
&& projectRow.appliedPluginSnapshotId.length > 0;
if (!hasPin) {
const fallbackPluginId = defaultScenarioPluginIdForKind(
projectRow?.metadata?.kind,
);
if (fallbackPluginId && getInstalledPlugin(db, fallbackPluginId)) {
runResolveBody = { ...req.body, pluginId: fallbackPluginId };
}
}
}
const resolved = resolvePluginSnapshot({
db,
body: req.body,
body: runResolveBody,
projectId: req.body.projectId,
conversationId: typeof req.body.conversationId === 'string'
? req.body.conversationId
@ -7597,9 +7856,16 @@ export async function startServer({
registry: registryView,
});
if (resolved && !resolved.ok) {
return res.status(resolved.status).json(resolved.body);
if (!explicitPlugin) {
console.warn(
`[plugins] default-scenario fallback skipped for run on project ${req.body.projectId}: ${resolved.body?.error?.code ?? 'unknown'}`,
);
} else {
return res.status(resolved.status).json(resolved.body);
}
} else {
resolvedSnapshot = resolved;
}
resolvedSnapshot = resolved;
}
const meta = { ...(req.body || {}) };
if (resolvedSnapshot?.ok) {

View file

@ -75,6 +75,33 @@ describe('applyPlugin', () => {
expect(result.result.appliedPlugin.inputs.audience).toBe('general');
});
it('resolves localized use-case queries at apply time', () => {
const base = pluginFixture();
const result = applyPlugin({
plugin: {
...base,
manifest: {
...base.manifest,
od: {
...base.manifest.od,
useCase: {
query: {
en: 'Generate a {{topic}} brief.',
'zh-CN': '生成一份关于 {{topic}} 的简报。',
},
},
},
},
},
inputs: { topic: 'design' },
registry: REGISTRY,
locale: 'zh-CN',
});
expect(result.result.query).toBe('生成一份关于 {{topic}} 的简报。');
expect(result.result.appliedPlugin.query).toBe('生成一份关于 {{topic}} 的简报。');
});
it('grants trusted defaults plus required caps for a trusted plugin', () => {
const result = applyPlugin({ plugin: pluginFixture(), inputs: { topic: 'design' }, registry: REGISTRY });
for (const cap of TRUSTED_DEFAULT_CAPABILITIES) {

View file

@ -0,0 +1,165 @@
// Stage A of plugin-driven-flow-plan — plugin-local SKILL.md flow.
//
// Covers:
// - `pickFirstSkillId` returns undefined for local `./SKILL.md` refs
// (so the project record never stores a phantom skill id).
// - `pickFirstLocalSkillPath` exposes the local path for the daemon's
// prompt composer to read on demand.
// - `loadPluginLocalSkill` reads the file, strips frontmatter and
// produces the `{ body, name, dir }` shape the composer drops into
// the `## Active skill` slot.
import { describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import os from 'node:os';
import {
applyPlugin,
pickFirstLocalSkillPath,
} from '../src/plugins/apply.js';
import { loadPluginLocalSkill } from '../src/plugins/local-skill.js';
import type { InstalledPluginRecord, PluginManifest } from '@open-design/contracts';
function manifestWithSkills(skills: Array<{ ref?: string; path?: string }>): PluginManifest {
return {
name: 'fixture-plugin',
title: 'Fixture Plugin',
version: '0.1.0',
description: 'Stage A test fixture.',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: { query: 'Generate a {{topic}} brief.' },
inputs: [{ name: 'topic', type: 'string', required: false, default: 'design' }],
context: { skills },
capabilities: ['prompt:inject'],
},
};
}
function pluginRecord(fsPath: string, manifest: PluginManifest): InstalledPluginRecord {
return {
id: 'fixture-plugin',
title: 'Fixture Plugin',
version: '0.1.0',
sourceKind: 'local',
source: fsPath,
sourceMarketplaceId: undefined,
pinnedRef: undefined,
sourceDigest: undefined,
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
fsPath,
installedAt: 0,
updatedAt: 0,
manifest,
};
}
const REGISTRY = {
skills: [{ id: 'sample-skill', title: 'Sample Skill' }],
designSystems: [],
craft: [],
atoms: [],
};
describe('plugin-local SKILL.md ref detection', () => {
it('pickFirstLocalSkillPath returns the relative path for `./SKILL.md`', () => {
const manifest = manifestWithSkills([{ path: './SKILL.md' }]);
expect(pickFirstLocalSkillPath(manifest)).toBe('./SKILL.md');
});
it('pickFirstLocalSkillPath ignores `ref` entries (those are global skill ids)', () => {
const manifest = manifestWithSkills([{ ref: 'sample-skill' }]);
expect(pickFirstLocalSkillPath(manifest)).toBeUndefined();
});
it('apply does not leak `./SKILL.md` into projectMetadata.skillId', () => {
const manifest = manifestWithSkills([{ path: './SKILL.md' }]);
const computed = applyPlugin({
plugin: pluginRecord('/tmp/does-not-need-to-exist', manifest),
inputs: { topic: 'design' },
registry: REGISTRY,
});
// A local skill ref is plugin-private and must never set the
// project's skill id; otherwise `findSkillById` later returns null
// and the active-skill block silently drops out.
expect(computed.result.projectMetadata.skillId).toBeUndefined();
});
it('apply keeps the global `ref` skill id flowing through to projectMetadata', () => {
const manifest = manifestWithSkills([{ ref: 'sample-skill' }]);
const computed = applyPlugin({
plugin: pluginRecord('/tmp/does-not-need-to-exist', manifest),
inputs: { topic: 'design' },
registry: REGISTRY,
});
expect(computed.result.projectMetadata.skillId).toBe('sample-skill');
});
});
describe('loadPluginLocalSkill', () => {
it('reads SKILL.md, strips frontmatter, and returns body/name/dir', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'od-plugin-local-skill-'));
try {
const skillPath = path.join(dir, 'SKILL.md');
await writeFile(
skillPath,
['---', 'name: fixture-plugin', 'mode: deck', '---', '', '# Body header', '', 'Body line.'].join('\n'),
'utf8',
);
const manifest = manifestWithSkills([{ path: './SKILL.md' }]);
const local = await loadPluginLocalSkill(pluginRecord(dir, manifest));
expect(local).not.toBeNull();
expect(local!.body.startsWith('# Body header')).toBe(true);
expect(local!.body).toContain('Body line.');
expect(local!.name).toBe('Fixture Plugin');
expect(local!.dir).toBe(dir);
expect(local!.relpath).toBe('SKILL.md');
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('returns null when the manifest has no local skill ref', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'od-plugin-local-skill-'));
try {
const manifest = manifestWithSkills([{ ref: 'sample-skill' }]);
const local = await loadPluginLocalSkill(pluginRecord(dir, manifest));
expect(local).toBeNull();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('returns null when the referenced file is missing', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'od-plugin-local-skill-'));
try {
const manifest = manifestWithSkills([{ path: './SKILL.md' }]);
const local = await loadPluginLocalSkill(pluginRecord(dir, manifest));
expect(local).toBeNull();
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('refuses `..` path traversal in the ref', async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), 'od-plugin-local-skill-'));
try {
// Create a SKILL.md outside the plugin folder and try to point at it.
const escapeRoot = await mkdtemp(path.join(os.tmpdir(), 'od-plugin-escape-'));
await writeFile(path.join(escapeRoot, 'SKILL.md'), '# bad', 'utf8');
const pluginDir = path.join(dir, 'plugin');
await mkdir(pluginDir, { recursive: true });
const manifest = manifestWithSkills([
{ path: '../SKILL.md' },
]);
const local = await loadPluginLocalSkill(pluginRecord(pluginDir, manifest));
expect(local).toBeNull();
await rm(escapeRoot, { recursive: true, force: true });
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});

View file

@ -2,7 +2,7 @@
//
// Renders a narrow icon-only column. The first slot is the brand
// logo (clicking navigates to home), followed by primary
// actions (new project, home, projects, tasks, design systems, integrations). A small
// actions (new project, home, projects, tasks, plugins, design systems, integrations). A small
// help launcher sits at the bottom and opens a popover with the
// canonical "ask for help / submit a feature / what's new / download
// desktop" external links. Language switching and other account-
@ -14,7 +14,13 @@ import { EntryHelpMenu } from './EntryHelpMenu';
import { Icon } from './Icon';
import { useT } from '../i18n';
export type EntryView = 'home' | 'projects' | 'tasks' | 'design-systems' | 'integrations';
export type EntryView =
| 'home'
| 'projects'
| 'tasks'
| 'plugins'
| 'design-systems'
| 'integrations';
interface Props {
view: EntryView;
@ -104,6 +110,15 @@ export function EntryNavRail({ view, onViewChange, onNewProject }: Props) {
>
<Icon name="kanban" size={18} />
</NavButton>
<NavButton
active={view === 'plugins'}
ariaLabel="Plugins"
tooltip="Plugins"
onClick={() => onViewChange('plugins')}
testId="entry-nav-plugins"
>
<Icon name="grid" size={18} />
</NavButton>
<NavButton
active={view === 'design-systems'}
ariaLabel={t('entry.navDesignSystems')}

View file

@ -9,7 +9,10 @@
// thin wrapper that passes data and callbacks through to this shell.
import { useEffect, useMemo, useRef, useState } from 'react';
import type { ConnectorDetail } from '@open-design/contracts';
import {
defaultScenarioPluginIdForKind,
type ConnectorDetail,
} from '@open-design/contracts';
import { LOCALE_LABEL, LOCALES, useI18n, useT, type Locale } from '../i18n';
import { navigate, useRoute } from '../router';
import type {
@ -20,7 +23,6 @@ import type {
DesignSystemSummary,
ExecMode,
Project,
ProjectKind,
ProjectMetadata,
ProjectTemplate,
PromptTemplateSummary,
@ -39,6 +41,7 @@ import { Icon } from './Icon';
import { IntegrationsView, type IntegrationTab } from './IntegrationsView';
import { InlineModelSwitcher } from './InlineModelSwitcher';
import { NewProjectModal } from './NewProjectModal';
import { PluginsView } from './PluginsView';
import type { CreateInput } from './NewProjectPanel';
import type { PluginLoopSubmit } from './PluginLoopHome';
import { TasksView } from './TasksView';
@ -50,24 +53,13 @@ import { TasksView } from './TasksView';
// markup — both surfaces are always present, and CSS toggles
// `display` based on `--compact-topbar` breakpoint (900px).
// Default scenario plugin for each project kind. The modal-based
// create flow no longer surfaces a plugin picker — every submission
// transparently binds the matching scenario so the project lands in a
// running pipeline. Add a row here when a kind-specific scenario
// plugin ships; until then everything routes through od-new-generation
// which already adapts on metadata.kind.
const DEFAULT_SCENARIO_PLUGIN_BY_KIND: Record<ProjectKind, string | null> = {
prototype: 'od-new-generation',
deck: 'od-new-generation',
template: 'od-new-generation',
image: 'od-new-generation',
video: 'od-new-generation',
audio: 'od-new-generation',
other: 'od-new-generation',
};
// Default scenario plugin for each project kind. The mapping lives in
// `@open-design/contracts` so the daemon's `/api/projects` and
// `/api/runs` fallbacks resolve to the same plugin id when no
// `pluginId` is on the request body — plan §3.3 of
// `specs/current/plugin-driven-flow-plan.md`.
function defaultPluginIdForKind(metadata: ProjectMetadata): string | null {
return DEFAULT_SCENARIO_PLUGIN_BY_KIND[metadata.kind] ?? null;
return defaultScenarioPluginIdForKind(metadata.kind);
}
// Theme options exposed in the avatar-popover appearance submenu.
@ -635,6 +627,7 @@ export function EntryShell({
onOpenOrbitSettings={() => onOpenSettings('orbit')}
/>
) : null}
{view === 'plugins' ? <PluginsView /> : null}
{view === 'design-systems' ? (
designSystemsLoading ? (
<CenteredLoader label={t('common.loading')} />

View file

@ -7,7 +7,8 @@
// composed with the recent-projects strip and plugins section
// without owning their data lifecycles.
import { forwardRef } from 'react';
import { forwardRef, useMemo, useState } from 'react';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { Icon } from './Icon';
export interface HomeHeroSubmitHandler {
@ -20,6 +21,10 @@ interface Props {
onSubmit: HomeHeroSubmitHandler;
activePluginTitle: string | null;
onClearActivePlugin: () => void;
pluginOptions: InstalledPluginRecord[];
pluginsLoading: boolean;
pendingPluginId: string | null;
onPickPlugin: (record: InstalledPluginRecord, nextPrompt: string | null) => void;
contextItemCount: number;
error: string | null;
}
@ -31,15 +36,45 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
onSubmit,
activePluginTitle,
onClearActivePlugin,
pluginOptions,
pluginsLoading,
pendingPluginId,
onPickPlugin,
contextItemCount,
error,
},
ref,
) {
const [selectedIndex, setSelectedIndex] = useState(0);
const canSubmit = prompt.trim().length > 0;
const placeholder = activePluginTitle
? 'Edit the example query or write your own…'
: 'What do you want to design? Type a prompt, or pick a plugin below…';
: 'What do you want to design? Type a prompt, @search a plugin, or pick one below…';
const mention = getPluginMention(prompt);
const pickerOptions = useMemo(() => {
if (!mention) return [];
const q = mention.query.toLowerCase();
return pluginOptions
.filter((plugin) => {
if (!q) return true;
return [
plugin.title,
plugin.id,
plugin.manifest?.description ?? '',
...(plugin.manifest?.tags ?? []),
]
.join(' ')
.toLowerCase()
.includes(q);
})
.slice(0, 6);
}, [mention, pluginOptions]);
const pickerOpen = Boolean(mention) && (pluginsLoading || pickerOptions.length > 0);
function pickPlugin(record: InstalledPluginRecord) {
const nextPrompt = mention ? replaceMentionToken(prompt, mention) : null;
onPickPlugin(record, nextPrompt);
}
return (
<section className="home-hero" data-testid="home-hero">
@ -83,8 +118,29 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
className="home-hero__input"
data-testid="home-hero-input"
value={prompt}
onChange={(e) => onPromptChange(e.target.value)}
onChange={(e) => {
onPromptChange(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={(e) => {
if (pickerOpen && pickerOptions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((idx) => (idx + 1) % pickerOptions.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((idx) => (idx - 1 + pickerOptions.length) % pickerOptions.length);
return;
}
if (e.key === 'Tab') {
e.preventDefault();
const selected = pickerOptions[selectedIndex] ?? pickerOptions[0];
if (selected) pickPlugin(selected);
return;
}
}
if (
e.key === 'Enter' &&
!e.shiftKey &&
@ -93,12 +149,56 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
!e.altKey
) {
e.preventDefault();
if (pickerOpen && pickerOptions.length > 0) {
const selected = pickerOptions[selectedIndex] ?? pickerOptions[0];
if (selected) pickPlugin(selected);
return;
}
if (canSubmit) onSubmit();
}
}}
placeholder={placeholder}
rows={3}
aria-controls={pickerOpen ? 'home-hero-plugin-picker' : undefined}
aria-expanded={pickerOpen}
/>
{pickerOpen ? (
<div
id="home-hero-plugin-picker"
className="home-hero__plugin-picker"
role="listbox"
aria-label="Plugin search results"
data-testid="home-hero-plugin-picker"
>
{pluginsLoading ? (
<div className="home-hero__plugin-picker-empty">Loading plugins</div>
) : (
pickerOptions.map((plugin, idx) => (
<button
key={plugin.id}
type="button"
role="option"
aria-selected={idx === selectedIndex}
className={`home-hero__plugin-option${idx === selectedIndex ? ' is-active' : ''}`}
onMouseEnter={() => setSelectedIndex(idx)}
onMouseDown={(event) => {
event.preventDefault();
pickPlugin(plugin);
}}
disabled={pendingPluginId !== null}
>
<span className="home-hero__plugin-option-main">
<span>{plugin.title}</span>
<span>{plugin.manifest?.description ?? plugin.id}</span>
</span>
<span className="home-hero__plugin-option-meta">
{pendingPluginId === plugin.id ? 'Applying…' : plugin.sourceKind}
</span>
</button>
))
)}
</div>
) : null}
<div className="home-hero__input-foot">
<span className="home-hero__hint">
<kbd></kbd> to run · <kbd>Shift</kbd>+<kbd></kbd> for new line
@ -125,3 +225,31 @@ export const HomeHero = forwardRef<HTMLTextAreaElement, Props>(function HomeHero
</section>
);
});
interface PluginMention {
start: number;
end: number;
query: string;
}
function getPluginMention(value: string): PluginMention | null {
const start = value.lastIndexOf('@');
if (start < 0) return null;
const before = value[start - 1];
if (before && !/\s/.test(before)) return null;
const tail = value.slice(start + 1);
const match = /^[^\s@]*/.exec(tail);
if (!match) return null;
return {
start,
end: start + 1 + match[0].length,
query: match[0],
};
}
function replaceMentionToken(value: string, mention: PluginMention): string | null {
const before = value.slice(0, mention.start).trimEnd();
const after = value.slice(mention.end).trimStart();
const next = [before, after].filter(Boolean).join(' ').trim();
return next.length > 0 ? next : null;
}

View file

@ -8,15 +8,17 @@
// textarea can live centered in the hero.
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
ApplyResult,
InstalledPluginRecord,
import {
resolveLocalizedText,
type ApplyResult,
type InstalledPluginRecord,
} from '@open-design/contracts';
import {
applyPlugin,
listPlugins,
renderPluginBriefTemplate,
} from '../state/projects';
import { useI18n } from '../i18n';
import type { Project } from '../types';
import { HomeHero } from './HomeHero';
import { PluginDetailsModal } from './PluginDetailsModal';
@ -45,6 +47,7 @@ export function HomeView({
onOpenProject,
onViewAllProjects,
}: Props) {
const { locale } = useI18n();
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [pluginsLoading, setPluginsLoading] = useState(true);
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
@ -71,10 +74,10 @@ export function HomeView({
[active],
);
async function usePlugin(record: InstalledPluginRecord) {
async function usePlugin(record: InstalledPluginRecord, nextPrompt?: string | null) {
setPendingApplyId(record.id);
setError(null);
const result = await applyPlugin(record.id, {});
const result = await applyPlugin(record.id, { locale });
setPendingApplyId(null);
if (!result) {
setError(`Failed to apply ${record.title}. Make sure the daemon is reachable.`);
@ -85,8 +88,10 @@ export function HomeView({
if (field.default !== undefined) inputs[field.name] = field.default;
}
setActive({ record, result, inputs });
const query = result.query ?? record.manifest?.od?.useCase?.query ?? '';
if (query) {
const query = result.query || resolveLocalizedText(record.manifest?.od?.useCase?.query, locale);
if (nextPrompt !== undefined && nextPrompt !== null) {
setPrompt(nextPrompt);
} else if (query) {
setPrompt(renderPluginBriefTemplate(query, inputs));
}
setDetailsRecord(null);
@ -119,6 +124,10 @@ export function HomeView({
onSubmit={submit}
activePluginTitle={active?.record.title ?? null}
onClearActivePlugin={clearActive}
pluginOptions={plugins}
pluginsLoading={pluginsLoading}
pendingPluginId={pendingApplyId}
onPickPlugin={(record, nextPrompt) => void usePlugin(record, nextPrompt)}
contextItemCount={contextItemCount}
error={error}
/>

View file

@ -12,6 +12,7 @@ import type {
InstalledPluginRecord,
} from '@open-design/contracts';
import { applyPlugin, listPlugins } from '../state/projects';
import { useI18n } from '../i18n';
interface Props {
// Active project the apply will be scoped to. Omit on Home (the
@ -43,6 +44,7 @@ interface Props {
}
export function InlinePluginsRail(props: Props) {
const { locale } = useI18n();
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [pendingId, setPendingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@ -68,6 +70,7 @@ export function InlinePluginsRail(props: Props) {
setError(null);
const result = await applyPlugin(record.id, {
...(props.projectId ? { projectId: props.projectId } : {}),
locale,
});
setPendingId(null);
if (!result) {

View file

@ -11,12 +11,14 @@ import { useEffect, useState } from 'react';
import type { ApplyResult, InstalledPluginRecord } from '@open-design/contracts';
import { applyPlugin } from '../state/projects';
import { navigate } from '../router';
import { useI18n } from '../i18n';
interface Props {
pluginId: string;
}
export function PluginDetailView(props: Props) {
const { locale } = useI18n();
const [plugin, setPlugin] = useState<InstalledPluginRecord | null>(null);
const [error, setError] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
@ -80,7 +82,7 @@ export function PluginDetailView(props: Props) {
const onUse = async () => {
setApplying(true);
setError(null);
const result = await applyPlugin(plugin.id);
const result = await applyPlugin(plugin.id, { locale });
setApplying(false);
if (!result) {
setError('Apply failed. Make sure the daemon is reachable.');

View file

@ -1,13 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
ApplyResult,
InstalledPluginRecord,
import {
resolveLocalizedText,
type ApplyResult,
type InstalledPluginRecord,
} from '@open-design/contracts';
import {
applyPlugin,
listPlugins,
renderPluginBriefTemplate,
} from '../state/projects';
import { useI18n } from '../i18n';
import { Icon } from './Icon';
import { PluginDetailsModal } from './PluginDetailsModal';
import { authorInitials, derivePluginSourceLinks } from '../runtime/plugin-source';
@ -31,6 +33,7 @@ interface ActivePlugin {
}
export function PluginLoopHome({ onSubmit }: Props) {
const { locale } = useI18n();
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [loading, setLoading] = useState(true);
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
@ -68,7 +71,7 @@ export function PluginLoopHome({ onSubmit }: Props) {
async function usePlugin(record: InstalledPluginRecord) {
setPendingApplyId(record.id);
setError(null);
const result = await applyPlugin(record.id, {});
const result = await applyPlugin(record.id, { locale });
setPendingApplyId(null);
if (!result) {
setError(`Failed to apply ${record.title}. Make sure the daemon is reachable.`);
@ -79,7 +82,7 @@ export function PluginLoopHome({ onSubmit }: Props) {
if (field.default !== undefined) inputs[field.name] = field.default;
}
setActive({ record, result, inputs });
const query = result.query ?? record.manifest?.od?.useCase?.query ?? '';
const query = result.query || resolveLocalizedText(record.manifest?.od?.useCase?.query, locale);
if (query) {
setPrompt(renderPluginBriefTemplate(query, inputs));
}

View file

@ -32,6 +32,9 @@ interface Props {
pendingApplyId: string | null;
onUse: (record: InstalledPluginRecord) => void;
onOpenDetails: (record: InstalledPluginRecord) => void;
title?: string;
subtitle?: string;
emptyMessage?: string;
}
export function PluginsHomeSection({
@ -41,6 +44,9 @@ export function PluginsHomeSection({
pendingApplyId,
onUse,
onOpenDetails,
title = 'Community',
subtitle = 'Things you can do and tasks to complete — packaged as plugins. Pick one to load a starter prompt, or type freely above.',
emptyMessage = 'Catalog is empty. Bundled plugins ship with Open Design and should appear here automatically — try restarting the daemon if this persists.',
}: Props) {
const {
visiblePlugins,
@ -62,9 +68,9 @@ export function PluginsHomeSection({
<section className="plugins-home" data-testid="plugins-home-section">
<header className="plugins-home__head">
<div className="plugins-home__heading">
<h2 className="plugins-home__title">Community</h2>
<h2 className="plugins-home__title">{title}</h2>
<p className="plugins-home__subtitle">
Things you can do and tasks to complete packaged as plugins. Pick one to load a starter prompt, or type freely above.
{subtitle}
</p>
</div>
<div className="plugins-home__head-tools">
@ -79,8 +85,7 @@ export function PluginsHomeSection({
<div className="plugins-home__empty">Loading catalog</div>
) : visiblePlugins.length === 0 ? (
<div className="plugins-home__empty">
Catalog is empty. Bundled plugins ship with Open Design and should appear
here automatically try restarting the daemon if this persists.
{emptyMessage}
</div>
) : (
<>

View file

@ -39,6 +39,7 @@ import {
applyPlugin,
renderPluginBriefTemplate,
} from '../state/projects';
import { useI18n } from '../i18n';
import { ContextChipStrip } from './ContextChipStrip';
import { InlinePluginsRail } from './InlinePluginsRail';
import { PluginInputsForm } from './PluginInputsForm';
@ -91,6 +92,7 @@ export interface PluginsSectionHandle {
export const PluginsSection = forwardRef<PluginsSectionHandle, Props>(
function PluginsSection(props, ref) {
const { locale } = useI18n();
const [applied, setApplied] = useState<ApplyResult | null>(null);
const [activeRecord, setActiveRecord] = useState<InstalledPluginRecord | null>(null);
const [pluginInputs, setPluginInputs] = useState<Record<string, unknown>>({});
@ -141,6 +143,7 @@ export const PluginsSection = forwardRef<PluginsSectionHandle, Props>(
applyById: async (pluginId, record = null) => {
const result = await applyPlugin(pluginId, {
...(props.projectId ? { projectId: props.projectId } : {}),
locale,
});
if (!result) return null;
handleApplied(record, result);
@ -149,7 +152,7 @@ export const PluginsSection = forwardRef<PluginsSectionHandle, Props>(
clear,
getActiveRecord: () => activeRecord,
}),
[props.projectId, handleApplied, clear, activeRecord],
[props.projectId, locale, handleApplied, clear, activeRecord],
);
const showRail = props.showRail ?? true;

View file

@ -0,0 +1,407 @@
import { useEffect, useMemo, useState } from 'react';
import type { ApplyResult, InstalledPluginRecord, PluginSourceKind } from '@open-design/contracts';
import {
applyPlugin,
installPluginSource,
listPluginMarketplaces,
listPlugins,
type PluginInstallOutcome,
type PluginMarketplace,
} from '../state/projects';
import { Icon } from './Icon';
import { PluginDetailsModal } from './PluginDetailsModal';
import { PluginsHomeSection } from './PluginsHomeSection';
import { useI18n } from '../i18n';
type PluginsTab = 'community' | 'mine' | 'marketplaces' | 'team';
const USER_SOURCE_KINDS = new Set<PluginSourceKind>([
'user',
'project',
'marketplace',
'github',
'url',
'local',
]);
const PLUGINS_TABS: ReadonlyArray<{
id: PluginsTab;
label: string;
hint: string;
}> = [
{ id: 'community', label: 'Community', hint: 'Official catalog' },
{ id: 'mine', label: 'My plugins', hint: 'User-installed' },
{ id: 'marketplaces', label: 'Marketplaces', hint: 'Catalog sources' },
{ id: 'team', label: 'Team / Enterprise', hint: 'Coming soon' },
];
export function PluginsView() {
const { locale } = useI18n();
const [plugins, setPlugins] = useState<InstalledPluginRecord[]>([]);
const [marketplaces, setMarketplaces] = useState<PluginMarketplace[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<PluginsTab>('community');
const [source, setSource] = useState('');
const [importOpen, setImportOpen] = useState(false);
const [installing, setInstalling] = useState(false);
const [pendingApplyId, setPendingApplyId] = useState<string | null>(null);
const [activePlugin, setActivePlugin] = useState<{
record: InstalledPluginRecord;
result: ApplyResult;
} | null>(null);
const [detailsRecord, setDetailsRecord] = useState<InstalledPluginRecord | null>(null);
const [notice, setNotice] = useState<PluginInstallOutcome | { ok: boolean; message: string } | null>(null);
async function refresh() {
setLoading(true);
const [rows, catalogs] = await Promise.all([listPlugins(), listPluginMarketplaces()]);
setPlugins(rows);
setMarketplaces(catalogs);
setLoading(false);
}
useEffect(() => {
void refresh();
}, []);
const officialPlugins = useMemo(
() => plugins.filter((plugin) => plugin.sourceKind === 'bundled'),
[plugins],
);
const userPlugins = useMemo(
() => plugins.filter((plugin) => USER_SOURCE_KINDS.has(plugin.sourceKind)),
[plugins],
);
async function handleInstall() {
const trimmed = source.trim();
if (!trimmed) return;
setInstalling(true);
setNotice(null);
const outcome = await installPluginSource(trimmed);
setInstalling(false);
setNotice(outcome);
if (outcome.ok) {
setSource('');
setImportOpen(false);
await refresh();
setActiveTab('mine');
}
}
async function handleUsePlugin(record: InstalledPluginRecord) {
setPendingApplyId(record.id);
setNotice(null);
const result = await applyPlugin(record.id, { locale });
setPendingApplyId(null);
if (!result) {
setNotice({
ok: false,
message: `Failed to apply ${record.title}. Make sure the daemon is reachable.`,
});
return;
}
setActivePlugin({ record, result });
setDetailsRecord(null);
setNotice({
ok: true,
message: `${record.title} is ready. Use it from Home with @ search or pick it from the gallery.`,
});
}
return (
<section className="plugins-view" aria-labelledby="plugins-title">
<header className="plugins-view__hero">
<div>
<p className="plugins-view__kicker">Plugins</p>
<h1 id="plugins-title" className="entry-section__title">
Plugins
</h1>
<p className="plugins-view__lede">
Browse the same visual plugin catalog from Home, then manage your
user plugins, marketplace sources, and future team catalogs here.
</p>
</div>
<div className="plugins-view__hero-actions">
<button
type="button"
className="plugins-view__primary"
onClick={() => setImportOpen((open) => !open)}
aria-expanded={importOpen}
data-testid="plugins-import-button"
>
<Icon name="plus" size={13} />
<span>Create / Import</span>
</button>
<div className="plugins-view__badge" aria-hidden="true">
<Icon name="grid" size={15} />
<span>Agent context</span>
</div>
</div>
</header>
<div className="plugins-view__stats" aria-label="Plugin summary">
<StatCard label="Official" value={officialPlugins.length} />
<StatCard label="My plugins" value={userPlugins.length} />
<StatCard label="Marketplaces" value={marketplaces.length} />
</div>
{importOpen ? (
<ImportPanel
source={source}
installing={installing}
onSourceChange={setSource}
onInstall={handleInstall}
/>
) : null}
<nav className="plugins-view__tabs" role="tablist" aria-label="Plugin areas">
{PLUGINS_TABS.map((tab) => {
const active = tab.id === activeTab;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={active}
className={`plugins-view__tab${active ? ' is-active' : ''}`}
onClick={() => setActiveTab(tab.id)}
data-testid={`plugins-tab-${tab.id}`}
>
<span className="plugins-view__tab-label">{tab.label}</span>
<span className="plugins-view__tab-hint">{tab.hint}</span>
</button>
);
})}
</nav>
{notice ? <Notice outcome={notice} /> : null}
<div className="plugins-view__gallery">
{loading ? <div className="plugins-view__empty">Loading plugins</div> : null}
{!loading && activeTab === 'community' ? (
<PluginsHomeSection
plugins={officialPlugins}
loading={false}
activePluginId={activePlugin?.record.id ?? null}
pendingApplyId={pendingApplyId}
onUse={(record) => void handleUsePlugin(record)}
onOpenDetails={setDetailsRecord}
title="Community"
subtitle="Things you can do and tasks to complete — packaged as plugins. Pick one to load a starter prompt, or use @ search from Home."
emptyMessage="No official plugins are registered yet. Restart the daemon if this looks wrong."
/>
) : null}
{!loading && activeTab === 'mine' ? (
<PluginsHomeSection
plugins={userPlugins}
loading={false}
activePluginId={activePlugin?.record.id ?? null}
pendingApplyId={pendingApplyId}
onUse={(record) => void handleUsePlugin(record)}
onOpenDetails={setDetailsRecord}
title="My plugins"
subtitle="Plugins installed into your user registry. They appear in @ search and can be consumed by the agent like official plugins."
emptyMessage="No user plugins yet. Use Create / Import to install from GitHub, a daemon-local path, an HTTPS archive, or a marketplace name."
/>
) : null}
{!loading && activeTab === 'marketplaces' ? (
<MarketplacesPanel marketplaces={marketplaces} />
) : null}
{activeTab === 'team' ? <TeamPanel /> : null}
</div>
{detailsRecord ? (
<PluginDetailsModal
record={detailsRecord}
onClose={() => setDetailsRecord(null)}
onUse={(record) => void handleUsePlugin(record)}
isApplying={pendingApplyId === detailsRecord.id}
/>
) : null}
</section>
);
}
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="plugins-view__stat">
<span className="plugins-view__stat-value">{value}</span>
<span className="plugins-view__stat-label">{label}</span>
</div>
);
}
function Notice({
outcome,
}: {
outcome: PluginInstallOutcome | { ok: boolean; message: string };
}) {
const warnings = 'warnings' in outcome ? outcome.warnings : [];
const log = 'log' in outcome ? outcome.log : [];
return (
<div className={`plugins-view__notice${outcome.ok ? ' is-success' : ' is-error'}`} role="status">
<div>{outcome.message}</div>
{warnings.length > 0 ? (
<div className="plugins-view__notice-sub">
{warnings.length} warning{warnings.length === 1 ? '' : 's'}
</div>
) : null}
{log.length > 0 ? (
<details className="plugins-view__notice-log">
<summary>Install log</summary>
<ul>
{log.map((line, idx) => (
<li key={`${line}-${idx}`}>{line}</li>
))}
</ul>
</details>
) : null}
</div>
);
}
function MarketplacesPanel({ marketplaces }: { marketplaces: PluginMarketplace[] }) {
return (
<section className="plugins-view__section" aria-labelledby="plugins-marketplaces-title">
<div className="plugins-view__section-head">
<div>
<h2 id="plugins-marketplaces-title">Configured marketplaces</h2>
<p>Marketplace manifests can resolve bare plugin names during install.</p>
</div>
<span className="plugins-view__section-count">{marketplaces.length}</span>
</div>
{marketplaces.length === 0 ? (
<div className="plugins-view__empty">
No marketplaces registered yet. Add one with <code>od marketplace add &lt;url&gt;</code>.
</div>
) : (
<div className="plugins-view__marketplaces">
{marketplaces.map((marketplace) => (
<article key={marketplace.id} className="plugins-view__marketplace">
<div>
<h3>{marketplace.manifest.name ?? marketplace.url}</h3>
<a href={marketplace.url} target="_blank" rel="noreferrer">
{marketplace.url}
</a>
</div>
<div className="plugins-view__meta">
<span>{marketplace.trust}</span>
<span>{marketplace.manifest.plugins?.length ?? 0} plugins</span>
</div>
</article>
))}
</div>
)}
</section>
);
}
function ImportPanel({
source,
installing,
onSourceChange,
onInstall,
}: {
source: string;
installing: boolean;
onSourceChange: (value: string) => void;
onInstall: () => void;
}) {
return (
<section className="plugins-view__section plugins-view__import" aria-labelledby="plugins-import-title">
<div className="plugins-view__section-head">
<div>
<h2 id="plugins-import-title">Create or import a plugin</h2>
<p>
Install into the user plugin registry from the sources the daemon
already understands.
</p>
</div>
</div>
<div className="plugins-view__install-card">
<label htmlFor="plugin-source">Plugin source</label>
<div className="plugins-view__source-row">
<input
id="plugin-source"
value={source}
onChange={(event) => onSourceChange(event.target.value)}
placeholder="github:owner/repo@main/plugins/my-plugin"
disabled={installing}
/>
<button
type="button"
className="plugins-view__primary"
onClick={onInstall}
disabled={installing || source.trim().length === 0}
>
{installing ? 'Installing…' : 'Install'}
</button>
</div>
<div className="plugins-view__source-help">
Supports <code>github:owner/repo[@ref][/subpath]</code>, daemon-local paths,
HTTPS <code>.tar.gz</code>/<code>.tgz</code> archives, or marketplace plugin names.
</div>
</div>
<div className="plugins-view__future-grid">
<FutureCard
icon="upload"
title="Upload zip"
body="Needs a browser upload endpoint that safely stages and scans archives before install."
/>
<FutureCard
icon="folder"
title="Upload folder"
body="Browsers cannot hand the daemon a folder path directly; this needs an explicit upload flow."
/>
<FutureCard
icon="edit"
title="Create from template"
body="Future plugin authoring can scaffold open-design.json, examples, and preview assets."
/>
</div>
</section>
);
}
function FutureCard({
icon,
title,
body,
}: {
icon: 'upload' | 'folder' | 'edit';
title: string;
body: string;
}) {
return (
<article className="plugins-view__future-card" aria-disabled="true">
<span className="plugins-view__future-icon" aria-hidden>
<Icon name={icon} size={16} />
</span>
<h3>{title}</h3>
<p>{body}</p>
</article>
);
}
function TeamPanel() {
return (
<section className="plugins-view__team" aria-labelledby="plugins-team-title">
<span className="plugins-view__future-icon" aria-hidden>
<Icon name="sparkles" size={18} />
</span>
<div>
<p className="plugins-view__kicker">Coming soon</p>
<h2 id="plugins-team-title">Private team marketplaces</h2>
<p>
This area is reserved for enterprise and team catalogs, private trust
policies, and shared plugin lifecycle controls.
</p>
</div>
</section>
);
}

View file

@ -9,7 +9,13 @@ import { useEffect, useState } from 'react';
// columns and each sub-view now owns a top-level path so the browser
// back/forward buttons work, deep links are shareable, and per-tab
// state isn't trapped behind a `useState` boundary.
export type EntryHomeView = 'home' | 'projects' | 'tasks' | 'design-systems' | 'integrations';
export type EntryHomeView =
| 'home'
| 'projects'
| 'tasks'
| 'plugins'
| 'design-systems'
| 'integrations';
export type Route =
| { kind: 'home'; view: EntryHomeView }
@ -40,6 +46,9 @@ export function parseRoute(pathname: string): Route {
if (parts[0] === 'tasks') {
return { kind: 'home', view: 'tasks' };
}
if (parts[0] === 'plugins' && !parts[1]) {
return { kind: 'home', view: 'plugins' };
}
if (parts[0] === 'integrations') {
return { kind: 'home', view: 'integrations' };
}
@ -61,6 +70,7 @@ export function buildPath(route: Route): string {
if (route.kind === 'home') {
if (route.view === 'projects') return '/projects';
if (route.view === 'tasks') return '/tasks';
if (route.view === 'plugins') return '/plugins';
if (route.view === 'design-systems') return '/design-systems';
if (route.view === 'integrations') return '/integrations';
return '/';

View file

@ -365,12 +365,152 @@ export async function listPlugins(): Promise<InstalledPluginRecord[]> {
}
}
export interface PluginInstallOutcome {
ok: boolean;
plugin?: InstalledPluginRecord;
warnings: string[];
message?: string;
log: string[];
}
interface PluginInstallEvent {
kind?: 'progress' | 'success' | 'error';
phase?: string;
message?: string;
plugin?: InstalledPluginRecord;
warnings?: string[];
}
export async function installPluginSource(source: string): Promise<PluginInstallOutcome> {
const log: string[] = [];
try {
const resp = await fetch('/api/plugins/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ source }),
});
if (!resp.ok) {
const message = await readErrorMessage(resp);
return { ok: false, warnings: [], message, log };
}
if (!resp.body) {
return {
ok: false,
warnings: [],
message: 'Install stream did not start.',
log,
};
}
let success: InstalledPluginRecord | undefined;
let warnings: string[] = [];
let errorMessage: string | undefined;
for await (const ev of readServerSentEvents(resp.body)) {
if (ev.message) log.push(ev.message);
if (ev.warnings) warnings = ev.warnings;
if (ev.kind === 'success') success = ev.plugin;
if (ev.kind === 'error') errorMessage = ev.message ?? 'Install failed.';
}
return {
ok: Boolean(success) && !errorMessage,
plugin: success,
warnings,
message: errorMessage ?? (success ? `Installed ${success.title}.` : 'Install finished.'),
log,
};
} catch (err) {
return {
ok: false,
warnings: [],
message: (err as Error).message,
log,
};
}
}
export async function upgradePlugin(id: string): Promise<PluginInstallOutcome> {
const log: string[] = [];
try {
const resp = await fetch(`/api/plugins/${encodeURIComponent(id)}/upgrade`, {
method: 'POST',
});
if (!resp.ok) {
const message = await readErrorMessage(resp);
return { ok: false, warnings: [], message, log };
}
if (!resp.body) {
return {
ok: false,
warnings: [],
message: 'Upgrade stream did not start.',
log,
};
}
let success: InstalledPluginRecord | undefined;
let warnings: string[] = [];
let errorMessage: string | undefined;
for await (const ev of readServerSentEvents(resp.body)) {
if (ev.message) log.push(ev.message);
if (ev.warnings) warnings = ev.warnings;
if (ev.kind === 'success') success = ev.plugin;
if (ev.kind === 'error') errorMessage = ev.message ?? 'Upgrade failed.';
}
return {
ok: Boolean(success) && !errorMessage,
plugin: success,
warnings,
message: errorMessage ?? (success ? `Upgraded ${success.title}.` : 'Upgrade finished.'),
log,
};
} catch (err) {
return {
ok: false,
warnings: [],
message: (err as Error).message,
log,
};
}
}
export async function uninstallPlugin(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/plugins/${encodeURIComponent(id)}/uninstall`, {
method: 'POST',
});
return resp.ok;
} catch {
return false;
}
}
export interface PluginMarketplace {
id: string;
url: string;
trust: 'official' | 'trusted' | 'restricted';
manifest: {
name?: string;
plugins?: Array<{ name: string; source: string; description?: string }>;
};
}
export async function listPluginMarketplaces(): Promise<PluginMarketplace[]> {
try {
const resp = await fetch('/api/marketplaces');
if (!resp.ok) return [];
const json = (await resp.json()) as { marketplaces?: PluginMarketplace[] };
return json.marketplaces ?? [];
} catch {
return [];
}
}
export async function applyPlugin(
pluginId: string,
options: {
inputs?: Record<string, unknown>;
projectId?: string;
grantCaps?: string[];
locale?: string;
} = {},
): Promise<ApplyResult | null> {
try {
@ -383,6 +523,7 @@ export async function applyPlugin(
inputs: options.inputs ?? {},
projectId: options.projectId,
grantCaps: options.grantCaps ?? [],
locale: options.locale,
}),
},
);
@ -394,6 +535,59 @@ export async function applyPlugin(
}
}
async function readErrorMessage(resp: Response): Promise<string> {
try {
const json = (await resp.json()) as {
error?: string | { message?: string };
};
if (typeof json.error === 'string') return json.error;
if (json.error?.message) return json.error.message;
} catch {
// Fall through to the status text below.
}
return resp.statusText || `HTTP ${resp.status}`;
}
async function* readServerSentEvents(
body: ReadableStream<Uint8Array>,
): AsyncGenerator<PluginInstallEvent, void, void> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split(/\n\n/);
buffer = parts.pop() ?? '';
for (const part of parts) {
const event = parseServerSentEvent(part);
if (event) yield event;
}
}
buffer += decoder.decode();
const event = parseServerSentEvent(buffer);
if (event) yield event;
} finally {
reader.releaseLock();
}
}
function parseServerSentEvent(raw: string): PluginInstallEvent | null {
const data = raw
.split('\n')
.filter((line) => line.startsWith('data:'))
.map((line) => line.slice(5).trimStart())
.join('\n');
if (!data) return null;
try {
return JSON.parse(data) as PluginInstallEvent;
} catch {
return null;
}
}
// Fetch the immutable snapshot pinned to a project / conversation.
// Used by ProjectView to surface the active plugin as a context chip
// on user messages instead of re-rendering the inline plugin rail

View file

@ -81,6 +81,7 @@
background: var(--bg-subtle);
}
.home-hero__input-card {
position: relative;
width: 100%;
max-width: 720px;
background: var(--bg-panel);
@ -160,6 +161,75 @@
.home-hero__input::placeholder {
color: var(--text-soft);
}
.home-hero__plugin-picker {
position: absolute;
left: 14px;
right: 14px;
top: calc(100% - 10px);
z-index: 20;
display: flex;
flex-direction: column;
gap: 3px;
max-height: 280px;
overflow: auto;
padding: 6px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
box-shadow: var(--shadow-lg, 0 14px 36px rgba(0, 0, 0, 0.14));
}
.home-hero__plugin-picker-empty {
padding: 10px;
color: var(--text-muted);
font-size: 12px;
}
.home-hero__plugin-option {
appearance: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease;
}
.home-hero__plugin-option:hover,
.home-hero__plugin-option.is-active {
border-color: var(--border-soft);
background: var(--bg-subtle);
}
.home-hero__plugin-option-main {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.home-hero__plugin-option-main span:first-child {
overflow: hidden;
color: var(--text-strong);
font-size: 13px;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-hero__plugin-option-main span:last-child {
overflow: hidden;
color: var(--text-muted);
font-size: 11.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.home-hero__plugin-option-meta {
flex: 0 0 auto;
color: var(--text-faint);
font-size: 11px;
}
.home-hero__input-foot {
display: flex;
align-items: center;

View file

@ -10,6 +10,7 @@
@import './home-hero.css';
@import './recent-projects.css';
@import './plugins-home.css';
@import './plugins-view.css';
@import './new-project-modal.css';
@import './integrations.css';
@import './tasks.css';

View file

@ -0,0 +1,466 @@
.plugins-view {
display: flex;
flex-direction: column;
gap: 18px;
}
.plugins-view__hero {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
}
.plugins-view__kicker {
margin: 0 0 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.11em;
text-transform: uppercase;
color: var(--text-muted);
}
.plugins-view__lede {
max-width: 620px;
margin: 8px 0 0;
color: var(--text-muted);
font-size: 14px;
line-height: 1.55;
}
.plugins-view__badge {
display: inline-flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
background: var(--bg-panel);
font-size: 12px;
}
.plugins-view__hero-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.plugins-view__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.plugins-view__stat {
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
}
.plugins-view__stat-value {
display: block;
color: var(--text-strong);
font-family: var(--serif);
font-size: 24px;
font-weight: 600;
line-height: 1;
}
.plugins-view__stat-label {
display: block;
margin-top: 6px;
color: var(--text-muted);
font-size: 12px;
}
.plugins-view__tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding: 5px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-subtle);
}
.plugins-view__tab {
appearance: none;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
padding: 9px 10px;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-muted);
text-align: left;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.plugins-view__tab:hover {
background: var(--bg-panel);
color: var(--text);
}
.plugins-view__tab.is-active {
background: var(--bg-panel);
border-color: var(--border);
color: var(--text-strong);
box-shadow: var(--shadow-sm);
}
.plugins-view__tab-label {
font-size: 13px;
font-weight: 650;
}
.plugins-view__tab-hint {
overflow: hidden;
max-width: 100%;
color: var(--text-faint);
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.plugins-view__gallery,
.plugins-view__section {
display: flex;
flex-direction: column;
gap: 14px;
}
.plugins-view__gallery > .plugins-home {
width: 100%;
}
.plugins-view__section-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.plugins-view__section-head h2,
.plugins-view__team h2,
.plugins-view__marketplace h3,
.plugins-view__future-card h3 {
margin: 0;
color: var(--text-strong);
}
.plugins-view__section-head h2 {
font-size: 18px;
}
.plugins-view__section-head p,
.plugins-view__team p,
.plugins-view__future-card p {
margin: 5px 0 0;
color: var(--text-muted);
font-size: 13px;
line-height: 1.45;
}
.plugins-view__section-count {
flex: 0 0 auto;
color: var(--text-faint);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.plugins-view__list,
.plugins-view__marketplaces {
display: flex;
flex-direction: column;
gap: 9px;
}
.plugins-view__row,
.plugins-view__marketplace,
.plugins-view__install-card,
.plugins-view__team {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
}
.plugins-view__row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 13px;
}
.plugins-view__row-main {
min-width: 0;
}
.plugins-view__row-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-strong);
font-size: 14px;
font-weight: 650;
}
.plugins-view__row-main p {
margin: 5px 0 0;
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.45;
}
.plugins-view__trust {
flex: 0 0 auto;
padding: 2px 6px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 650;
text-transform: uppercase;
}
.plugins-view__meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
color: var(--text-faint);
font-size: 11.5px;
}
.plugins-view__meta span {
padding: 2px 6px;
border: 1px solid var(--border-soft);
border-radius: 999px;
}
.plugins-view__row-actions {
display: flex;
align-items: center;
gap: 7px;
flex: 0 0 auto;
}
.plugins-view__primary,
.plugins-view__secondary,
.plugins-view__danger {
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 32px;
padding: 0 11px;
border-radius: var(--radius-sm);
font-size: 12px;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.plugins-view__primary {
border: 1px solid var(--accent);
background: var(--accent);
color: white;
}
.plugins-view__primary:hover:not(:disabled) {
background: var(--accent-strong);
border-color: var(--accent-strong);
}
.plugins-view__secondary {
border: 1px solid var(--border);
background: var(--bg-panel);
color: var(--text-muted);
}
.plugins-view__secondary:hover:not(:disabled) {
border-color: var(--border-strong);
color: var(--text);
}
.plugins-view__danger {
border: 1px solid var(--red-border);
background: var(--red-bg);
color: var(--red);
}
.plugins-view__primary:disabled,
.plugins-view__secondary:disabled,
.plugins-view__danger:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.plugins-view__marketplace {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 13px;
}
.plugins-view__marketplace a {
display: inline-block;
margin-top: 5px;
color: var(--text-muted);
font-size: 12px;
word-break: break-all;
}
.plugins-view__install-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.plugins-view__import {
padding: 14px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-panel);
box-shadow: var(--shadow-sm);
}
.plugins-view__install-card label {
color: var(--text-strong);
font-size: 13px;
font-weight: 650;
}
.plugins-view__source-row {
display: flex;
gap: 8px;
}
.plugins-view__source-row input {
flex: 1 1 auto;
min-width: 0;
}
.plugins-view__source-help {
color: var(--text-muted);
font-size: 12px;
line-height: 1.45;
}
.plugins-view__source-help code,
.plugins-view__empty code {
padding: 1px 5px;
border-radius: 3px;
background: var(--bg-subtle);
color: var(--text);
font-size: 11px;
}
.plugins-view__future-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.plugins-view__future-card {
padding: 13px;
border: 1px dashed var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
}
.plugins-view__future-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin-bottom: 8px;
border: 1px solid var(--border);
border-radius: 50%;
color: var(--text-muted);
background: var(--bg-subtle);
}
.plugins-view__team {
display: flex;
gap: 12px;
padding: 18px;
}
.plugins-view__empty {
padding: 20px 16px;
border: 1px dashed var(--border);
border-radius: var(--radius);
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
.plugins-view__notice {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-panel);
color: var(--text);
font-size: 13px;
}
.plugins-view__notice.is-success {
border-color: color-mix(in srgb, var(--green, #168a4a) 25%, var(--border));
}
.plugins-view__notice.is-error {
border-color: var(--red-border);
background: var(--red-bg);
color: var(--red);
}
.plugins-view__notice-sub {
margin-top: 3px;
color: var(--text-muted);
font-size: 12px;
}
.plugins-view__notice-log {
margin-top: 6px;
color: var(--text-muted);
font-size: 12px;
}
.plugins-view__notice-log ul {
margin: 6px 0 0;
padding-left: 18px;
}
@media (max-width: 900px) {
.plugins-view__hero,
.plugins-view__hero-actions,
.plugins-view__row,
.plugins-view__marketplace {
flex-direction: column;
}
.plugins-view__stats,
.plugins-view__tabs,
.plugins-view__future-grid {
grid-template-columns: 1fr;
}
.plugins-view__row-actions,
.plugins-view__source-row {
width: 100%;
}
.plugins-view__source-row {
flex-direction: column;
}
}

View file

@ -0,0 +1,62 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPluginRecord } from '@open-design/contracts';
import { HomeHero } from '../../src/components/HomeHero';
function makePlugin(id: string, title: string): InstalledPluginRecord {
return {
id,
title,
version: '1.0.0',
sourceKind: 'bundled',
source: '/tmp',
trust: 'bundled',
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: id,
version: '1.0.0',
title,
description: 'A plugin fixture',
tags: ['fixture'],
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
afterEach(() => {
cleanup();
});
describe('HomeHero plugin picker', () => {
it('opens plugin search from an @ token and returns the prompt without that token', () => {
const onPromptChange = vi.fn();
const onPickPlugin = vi.fn();
render(
<HomeHero
prompt="Make @sam"
onPromptChange={onPromptChange}
onSubmit={() => undefined}
activePluginTitle={null}
onClearActivePlugin={() => undefined}
pluginOptions={[makePlugin('sample-plugin', 'Sample Plugin')]}
pluginsLoading={false}
pendingPluginId={null}
onPickPlugin={onPickPlugin}
contextItemCount={0}
error={null}
/>,
);
expect(screen.getByTestId('home-hero-plugin-picker')).toBeTruthy();
fireEvent.mouseDown(screen.getByRole('option', { name: /sample plugin/i }));
expect(onPickPlugin).toHaveBeenCalledWith(
expect.objectContaining({ id: 'sample-plugin' }),
'Make',
);
});
});

View file

@ -0,0 +1,113 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { HomeView } from '../../src/components/HomeView';
import { I18nProvider } from '../../src/i18n';
const PLUGIN_ROW = {
id: 'localized-plugin',
title: 'Localized Plugin',
version: '1.0.0',
trust: 'trusted' as const,
sourceKind: 'bundled' as const,
source: '/tmp/localized',
capabilitiesGranted: ['prompt:inject'],
fsPath: '/tmp/localized',
installedAt: 0,
updatedAt: 0,
manifest: {
name: 'localized-plugin',
title: 'Localized Plugin',
version: '1.0.0',
description: 'A localized fixture',
od: {
kind: 'scenario',
taskKind: 'new-generation',
useCase: {
query: {
en: 'Make a {{topic}} brief.',
'zh-CN': '生成一份关于 {{topic}} 的简报。',
},
},
},
},
};
const APPLY_RESULT = {
ok: true,
query: '生成一份关于 {{topic}} 的简报。',
contextItems: [],
inputs: [{ name: 'topic', type: 'string', default: '设计系统' }],
assets: [],
mcpServers: [],
trust: 'trusted',
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
appliedPlugin: {
snapshotId: 'snap-1',
pluginId: 'localized-plugin',
pluginVersion: '1.0.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 0,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
},
projectMetadata: {},
};
describe('HomeView plugin i18n', () => {
afterEach(() => {
vi.unstubAllGlobals();
cleanup();
});
it('hydrates the Home prompt with the localized apply query', async () => {
const fetchMock = vi.fn(async (url) => {
if (typeof url === 'string' && url === '/api/plugins') {
return new Response(JSON.stringify({ plugins: [PLUGIN_ROW] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (typeof url === 'string' && url.includes('/apply')) {
return new Response(JSON.stringify(APPLY_RESULT), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
throw new Error(`unexpected fetch ${url}`);
});
vi.stubGlobal('fetch', fetchMock);
render(
<I18nProvider initial="zh-CN">
<HomeView
projects={[]}
onSubmit={() => undefined}
onOpenProject={() => undefined}
onViewAllProjects={() => undefined}
/>
</I18nProvider>,
);
fireEvent.click(await waitFor(() => screen.getByTestId('plugins-home-use-localized-plugin')));
const input = await screen.findByTestId('home-hero-input');
await waitFor(() => {
expect((input as HTMLTextAreaElement).value).toBe('生成一份关于 设计系统 的简报。');
});
const [, init] = fetchMock.mock.calls.find(([url]) => (
typeof url === 'string' && url.includes('/apply')
))!;
expect(JSON.parse(String(init?.body))).toMatchObject({ locale: 'zh-CN' });
});
});

View file

@ -0,0 +1,148 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPluginRecord, PluginSourceKind, TrustTier } from '@open-design/contracts';
import { PluginsView } from '../../src/components/PluginsView';
import {
applyPlugin,
installPluginSource,
listPluginMarketplaces,
listPlugins,
} from '../../src/state/projects';
vi.mock('../../src/router', () => ({
navigate: vi.fn(),
}));
vi.mock('../../src/state/projects', () => ({
applyPlugin: vi.fn(),
installPluginSource: vi.fn(),
listPluginMarketplaces: vi.fn(),
listPlugins: vi.fn(),
uninstallPlugin: vi.fn(),
upgradePlugin: vi.fn(),
}));
function makePlugin(
id: string,
sourceKind: PluginSourceKind,
trust: TrustTier,
): InstalledPluginRecord {
return {
id,
title: id === 'official-plugin' ? 'Official Plugin' : 'User Plugin',
version: '1.0.0',
sourceKind,
source: '/tmp',
trust,
capabilitiesGranted: ['prompt:inject'],
manifest: {
name: id,
version: '1.0.0',
title: id,
description: `${id} description`,
od: {
kind: 'scenario',
mode: 'prototype',
},
},
fsPath: '/tmp',
installedAt: 0,
updatedAt: 0,
};
}
const mockedListPlugins = vi.mocked(listPlugins);
const mockedListMarketplaces = vi.mocked(listPluginMarketplaces);
const mockedInstallPluginSource = vi.mocked(installPluginSource);
const mockedApplyPlugin = vi.mocked(applyPlugin);
beforeEach(() => {
mockedListPlugins.mockResolvedValue([
makePlugin('official-plugin', 'bundled', 'bundled'),
makePlugin('user-plugin', 'github', 'restricted'),
]);
mockedListMarketplaces.mockResolvedValue([
{
id: 'catalog-1',
url: 'https://example.com/open-design-marketplace.json',
trust: 'official',
manifest: {
name: 'Example Catalog',
plugins: [{ name: 'remote-plugin', source: 'github:owner/repo' }],
},
},
]);
mockedInstallPluginSource.mockResolvedValue({
ok: true,
plugin: makePlugin('new-plugin', 'github', 'restricted'),
warnings: [],
message: 'Installed New Plugin.',
log: ['Parsing manifest'],
});
mockedApplyPlugin.mockResolvedValue({
query: 'Make something.',
contextItems: [],
inputs: [],
assets: [],
mcpServers: [],
trust: 'restricted',
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
appliedPlugin: {
snapshotId: 'snap-1',
pluginId: 'official-plugin',
pluginVersion: '1.0.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: ['prompt:inject'],
capabilitiesRequired: ['prompt:inject'],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 0,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
},
projectMetadata: {},
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe('PluginsView', () => {
it('groups official and user-installed plugins', async () => {
render(<PluginsView />);
await waitFor(() => expect(screen.getAllByText('Official Plugin').length).toBeGreaterThan(0));
expect(screen.queryByText('User Plugin')).toBeNull();
fireEvent.click(screen.getByTestId('plugins-tab-mine'));
expect(screen.getAllByText('User Plugin').length).toBeGreaterThan(0);
expect(screen.queryByText('Official Plugin')).toBeNull();
});
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'));
fireEvent.change(screen.getByLabelText('Plugin source'), {
target: { value: 'github:owner/repo/plugins/my-plugin' },
});
fireEvent.click(screen.getByRole('button', { name: 'Install' }));
await waitFor(() =>
expect(mockedInstallPluginSource).toHaveBeenCalledWith(
'github:owner/repo/plugins/my-plugin',
),
);
expect(await screen.findByText('Installed New Plugin.')).toBeTruthy();
});
});

View file

@ -16,6 +16,11 @@ describe('router /marketplace', () => {
});
});
it('parses /plugins as the entry-shell plugins tab', () => {
expect(parseRoute('/plugins')).toEqual({ kind: 'home', view: 'plugins' });
expect(parseRoute('/plugins/')).toEqual({ kind: 'home', view: 'plugins' });
});
it('parses /plugins/<pluginId> as the same detail route (alias)', () => {
expect(parseRoute('/plugins/sample-plugin')).toEqual({
kind: 'marketplace-detail',
@ -61,7 +66,9 @@ describe('router entry sub-views', () => {
{ kind: 'home', view: 'home' } as Route,
{ kind: 'home', view: 'projects' } as Route,
{ kind: 'home', view: 'tasks' } as Route,
{ kind: 'home', view: 'plugins' } as Route,
{ kind: 'home', view: 'design-systems' } as Route,
{ kind: 'home', view: 'integrations' } as Route,
]) {
expect(parseRoute(buildPath(route))).toEqual(route);
}

View file

@ -0,0 +1,52 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { applyPlugin } from '../../src/state/projects';
describe('applyPlugin', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('passes the current locale to the daemon apply endpoint', async () => {
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({
query: '生成一份简报。',
contextItems: [],
inputs: [],
assets: [],
mcpServers: [],
projectMetadata: {},
trust: 'trusted',
capabilitiesGranted: [],
capabilitiesRequired: [],
appliedPlugin: {
snapshotId: 'snap-1',
pluginId: 'sample-plugin',
pluginVersion: '1.0.0',
manifestSourceDigest: 'a'.repeat(64),
inputs: {},
resolvedContext: { items: [] },
capabilitiesGranted: [],
capabilitiesRequired: [],
assetsStaged: [],
taskKind: 'new-generation',
appliedAt: 0,
connectorsRequired: [],
connectorsResolved: [],
mcpServers: [],
status: 'fresh',
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
await applyPlugin('sample-plugin', { locale: 'zh-CN' });
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(String(init?.body))).toMatchObject({
inputs: {},
grantCaps: [],
locale: 'zh-CN',
});
});
});

View file

@ -252,7 +252,10 @@ Rules of authorship:
},
"useCase": {
"query": "Make a 12-slide investor deck for a Series A SaaS startup targeting {{audience}} on {{topic}}.",
"query": {
"en": "Make a 12-slide investor deck for a Series A SaaS startup targeting {{audience}} on {{topic}}.",
"zh-CN": "为一家面向 {{audience}}、主题是 {{topic}} 的 A 轮 SaaS 初创公司制作一份 12 页投资人 deck。"
},
"exampleOutputs": [
{ "path": "./examples/b2b-saas/", "title": "B2B SaaS deck" }
]
@ -344,7 +347,7 @@ Rules of authorship:
- `od.kind` — registry classification (`skill` / `scenario` / `atom` / `bundle`).
- `od.taskKind` — one of the four product scenarios (`new-generation` / `code-migration` / `figma-migration` / `tune-collab`, see §1 "Four product scenarios"). Drives marketplace filters, default input templates, and the recommended pipeline starting point.
- `od.preview` — drives the marketplace card and detail page. `entry` is served sandboxed via the daemon (the existing `/api/skills/:id/example` plumbing extended to plugins).
- `od.useCase.query` — the exact text that lands in the brief field on click-to-use. `{{var}}` placeholders bind to `od.inputs`.
- `od.useCase.query` — the exact text that lands in the brief field on click-to-use. It may be a legacy string or a locale map keyed by BCP-47-style locale tags (for example `{ "en": "...", "zh-CN": "..." }`). Apply-time resolution tries the requested locale, base language, `en`, then the first available value. `{{var}}` placeholders bind to `od.inputs`.
- `od.context.*` — typed chips that hydrate the `ContextChipStrip` above the input. Each entry compiles to a `ContextItem` (§5.2).
- `od.context.atoms`**unordered set** declaring the atoms a plugin needs. The daemon uses them in default order; intended for simple plugins that don't customize flow.
- `od.pipeline`**ordered pipeline** in which the plugin author explicitly composes atoms into stages, loops, and termination conditions (§10.1). When both `od.pipeline` and `od.context.atoms` are present, `pipeline` wins; `context.atoms` is treated only as chip-strip metadata.

View file

@ -252,7 +252,10 @@ my-plugin/
},
"useCase": {
"query": "Make a 12-slide investor deck for a Series A SaaS startup targeting {{audience}} on {{topic}}.",
"query": {
"en": "Make a 12-slide investor deck for a Series A SaaS startup targeting {{audience}} on {{topic}}.",
"zh-CN": "为一家面向 {{audience}}、主题是 {{topic}} 的 A 轮 SaaS 初创公司制作一份 12 页投资人 deck。"
},
"exampleOutputs": [
{ "path": "./examples/b2b-saas/", "title": "B2B SaaS deck" }
]
@ -344,7 +347,7 @@ my-plugin/
- `od.kind`registry 里的分类(`skill` / `scenario` / `atom` / `bundle`)。
- `od.taskKind`:四类产品场景之一(`new-generation` / `code-migration` / `figma-migration` / `tune-collab`§1「四类产品场景」。决定 marketplace filter、初始 inputs 模板、推荐 pipeline 起点。
- `od.preview`:驱动 marketplace 卡片和详情页。`entry` 通过 daemon 以 sandboxed 方式服务(扩展现有 `/api/skills/:id/example` plumbing
- `od.useCase.query`:一键使用时进入 brief 字段的精确文本。`{{var}}` placeholder 绑定到 `od.inputs`
- `od.useCase.query`:一键使用时进入 brief 字段的精确文本。它可以是兼容旧 manifest 的字符串,也可以是按 BCP-47 风格 locale key 组织的文本映射(例如 `{ "en": "...", "zh-CN": "..." }`。apply 时会依次尝试请求的 locale、基础语言、`en`,最后回退到第一个可用值。`{{var}}` placeholder 绑定到 `od.inputs`
- `od.context.*`:用于填充输入框上方 `ContextChipStrip` 的类型化 chips。每一项都会编译成一个 `ContextItem`§5.2)。
- `od.context.atoms`**无序集合**——声明插件需要的 atoms。daemon 仅以默认顺序使用它们;用于不需要自定义流程的简单插件。
- `od.pipeline`**有序管线**——插件作者显式编排 atoms 的 stages、循环、终止条件§10.1)。当 `od.pipeline``od.context.atoms` 同时出现时pipeline 优先context.atoms 仅作为 chip strip 展示。

View file

@ -59,7 +59,12 @@
"useCase": {
"type": "object",
"properties": {
"query": { "type": "string" },
"query": {
"oneOf": [
{ "type": "string" },
{ "$ref": "#/$defs/LocalizedText" }
]
},
"exampleOutputs": {
"type": "array",
"items": {
@ -149,6 +154,11 @@
"properties": { "path": { "type": "string", "minLength": 1 } },
"additionalProperties": true
},
"LocalizedText": {
"type": "object",
"minProperties": 1,
"additionalProperties": { "type": "string" }
},
"Reference": {
"type": "object",
"properties": {

View file

@ -4,3 +4,4 @@ export * from './apply.js';
export * from './marketplace.js';
export * from './installed.js';
export * from './events.js';
export * from './scenario-defaults.js';

View file

@ -36,6 +36,36 @@ export const InputFieldSchema = z.object({
export type InputField = z.infer<typeof InputFieldSchema>;
export const LocalizedTextSchema = z.record(z.string()).refine(
(value) => Object.keys(value).length > 0,
{ message: 'Localized text must include at least one locale.' },
);
export type LocalizedText = string | z.infer<typeof LocalizedTextSchema>;
export function resolveLocalizedText(
value: LocalizedText | undefined,
locale?: string,
fallbackLocale = 'en',
): string {
if (!value) return '';
if (typeof value === 'string') return value;
const candidates = [
locale,
locale?.split('-')[0],
fallbackLocale,
fallbackLocale.split('-')[0],
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of candidates) {
const resolved = value[candidate];
if (typeof resolved === 'string' && resolved.length > 0) return resolved;
}
return Object.values(value).find((text) => text.length > 0) ?? '';
}
export const PipelineStageSchema = z.object({
id: z.string().min(1),
atoms: z.array(z.string()),
@ -137,7 +167,7 @@ export const PluginManifestSchema = z.object({
gif: z.string().optional(),
}).passthrough().optional(),
useCase: z.object({
query: z.string().optional(),
query: z.union([z.string(), LocalizedTextSchema]).optional(),
exampleOutputs: z.array(z.object({
path: z.string(),
title: z.string().optional(),

View file

@ -0,0 +1,62 @@
// Default scenario plugin bindings (plan §3.3 of plugin-driven-flow-plan).
//
// Both the web client (`EntryShell.handleCreate`) and the daemon
// (`/api/projects` + `/api/runs`) need to know which bundled scenario
// plugin to bind when the caller didn't pick one explicitly. Keeping
// the mapping in contracts lets both sides import the same table so the
// client and the server never disagree about what counts as the
// "default" plugin for a given project kind / task kind.
//
// Today every kind defaults to `od-new-generation`. The plan
// reserves slots for `od-media-generation` (Stage C) and the migration
// scenarios (`od-figma-migration`, `od-code-migration`) once the Home
// chip rail wires them in.
import type { ProjectKind } from '../api/projects.js';
import type { AppliedPluginSnapshot } from './apply.js';
export type TaskKind = AppliedPluginSnapshot['taskKind'];
// Plugin ids of the bundled `_official/scenarios/` rows. Kept as a
// string-literal union so a typo here surfaces as a type error in both
// the web shell and the daemon resolver.
export type DefaultScenarioPluginId =
| 'od-new-generation'
| 'od-figma-migration'
| 'od-code-migration'
| 'od-tune-collab';
export const DEFAULT_SCENARIO_PLUGIN_BY_KIND: Record<ProjectKind, DefaultScenarioPluginId> = {
prototype: 'od-new-generation',
deck: 'od-new-generation',
template: 'od-new-generation',
// image / video / audio fall back to `od-new-generation` until the
// Stage C scenario (`od-media-generation`) lands. Keeping them
// explicit in the map (instead of using `as Record<...>` shortcuts)
// documents the eventual migration target.
image: 'od-new-generation',
video: 'od-new-generation',
audio: 'od-new-generation',
other: 'od-new-generation',
};
export const DEFAULT_SCENARIO_PLUGIN_BY_TASK_KIND: Record<TaskKind, DefaultScenarioPluginId> = {
'new-generation': 'od-new-generation',
'figma-migration': 'od-figma-migration',
'code-migration': 'od-code-migration',
'tune-collab': 'od-tune-collab',
};
export function defaultScenarioPluginIdForKind(
kind: ProjectKind | undefined,
): DefaultScenarioPluginId | null {
if (!kind) return null;
return DEFAULT_SCENARIO_PLUGIN_BY_KIND[kind] ?? null;
}
export function defaultScenarioPluginIdForTaskKind(
taskKind: TaskKind | undefined,
): DefaultScenarioPluginId | null {
if (!taskKind) return null;
return DEFAULT_SCENARIO_PLUGIN_BY_TASK_KIND[taskKind] ?? null;
}

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import {
PluginManifestSchema,
resolveLocalizedText,
} from '../src/plugins/manifest.js';
describe('plugin manifest localized text', () => {
it('accepts legacy string use-case queries', () => {
const manifest = PluginManifestSchema.parse({
name: 'sample-plugin',
version: '1.0.0',
od: {
useCase: {
query: 'Make a {{topic}} brief.',
},
},
});
expect(manifest.od?.useCase?.query).toBe('Make a {{topic}} brief.');
});
it('accepts locale-map use-case queries', () => {
const manifest = PluginManifestSchema.parse({
name: 'sample-plugin',
version: '1.0.0',
od: {
useCase: {
query: {
en: 'Make a {{topic}} brief.',
'zh-CN': '围绕 {{topic}} 写一份简报。',
},
},
},
});
expect(resolveLocalizedText(manifest.od?.useCase?.query, 'zh-CN')).toBe(
'围绕 {{topic}} 写一份简报。',
);
});
it('falls back from exact locale to base language, English, then first value', () => {
expect(resolveLocalizedText({ en: 'English', zh: '中文' }, 'zh-CN')).toBe('中文');
expect(resolveLocalizedText({ 'zh-CN': '中文' }, 'fr')).toBe('中文');
});
});

View file

@ -0,0 +1,48 @@
// Plan §3.3 of plugin-driven-flow-plan — kind → bundled scenario plugin
// mapping. Web (`EntryShell`) and daemon (`/api/projects`, `/api/runs`)
// share this resolver; the test pins the table so a drift between the
// two surfaces is impossible.
import { describe, expect, it } from 'vitest';
import {
DEFAULT_SCENARIO_PLUGIN_BY_KIND,
DEFAULT_SCENARIO_PLUGIN_BY_TASK_KIND,
defaultScenarioPluginIdForKind,
defaultScenarioPluginIdForTaskKind,
} from '../src/plugins/scenario-defaults.js';
describe('defaultScenarioPluginIdForKind', () => {
it('maps every supported ProjectKind to a bundled scenario id', () => {
const expected: Record<string, string> = {
prototype: 'od-new-generation',
deck: 'od-new-generation',
template: 'od-new-generation',
image: 'od-new-generation',
video: 'od-new-generation',
audio: 'od-new-generation',
other: 'od-new-generation',
};
for (const [kind, pluginId] of Object.entries(expected)) {
expect(defaultScenarioPluginIdForKind(kind as never)).toBe(pluginId);
expect(DEFAULT_SCENARIO_PLUGIN_BY_KIND[kind as never]).toBe(pluginId);
}
});
it('returns null for an undefined kind so the daemon can skip the fallback', () => {
expect(defaultScenarioPluginIdForKind(undefined)).toBeNull();
});
});
describe('defaultScenarioPluginIdForTaskKind', () => {
it('maps every taskKind to the matching scenario plugin', () => {
expect(defaultScenarioPluginIdForTaskKind('new-generation')).toBe('od-new-generation');
expect(defaultScenarioPluginIdForTaskKind('figma-migration')).toBe('od-figma-migration');
expect(defaultScenarioPluginIdForTaskKind('code-migration')).toBe('od-code-migration');
expect(defaultScenarioPluginIdForTaskKind('tune-collab')).toBe('od-tune-collab');
expect(DEFAULT_SCENARIO_PLUGIN_BY_TASK_KIND['new-generation']).toBe('od-new-generation');
});
it('returns null when the taskKind is missing', () => {
expect(defaultScenarioPluginIdForTaskKind(undefined)).toBeNull();
});
});

View file

@ -35,6 +35,23 @@ describe('parseManifest', () => {
expect((result.manifest as Record<string, unknown>).futureField).toEqual({ hello: 'world' });
}
});
it('accepts localized use-case queries', () => {
const result = parseManifest(JSON.stringify({
name: 'sample-plugin',
version: '1.0.0',
od: {
useCase: {
query: {
en: 'Make a brief.',
'zh-CN': '写一份简报。',
},
},
},
}));
expect(result.ok).toBe(true);
});
});
describe('parseMarketplace', () => {

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Agentic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Agentic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Agentic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Airbnb design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Airbnb design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Airbnb design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Airtable design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Airtable design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Airtable design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Ant design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Ant design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Ant design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Apple design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Apple design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Apple design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Application design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Application design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Application design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Arc Browser design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Arc Browser design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Arc Browser design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Artistic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Artistic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Artistic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Atelier Zero design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Atelier Zero design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Atelier Zero design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Bento design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Bento design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Bento design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Binance.US design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Binance.US design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Binance.US design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the BMW M design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the BMW M design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the BMW M design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the BMW design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the BMW design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the BMW design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Bold design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Bold design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Bold design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Brutalism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Brutalism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Brutalism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Bugatti design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Bugatti design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Bugatti design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Cafe design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Cafe design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Cafe design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Cal.com design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Cal.com design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Cal.com design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Canva design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Canva design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Canva design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Claude (Anthropic) design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Claude (Anthropic) design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Claude (Anthropic) design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Clay design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Clay design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Clay design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Claymorphism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Claymorphism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Claymorphism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Clean design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Clean design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Clean design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the ClickHouse design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the ClickHouse design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the ClickHouse design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Cohere design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Cohere design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Cohere design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Coinbase design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Coinbase design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Coinbase design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Colorful design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Colorful design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Colorful design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Composio design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Composio design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Composio design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Contemporary design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Contemporary design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Contemporary design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Corporate design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Corporate design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Corporate design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Cosmic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Cosmic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Cosmic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Creative design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Creative design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Creative design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Cursor design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Cursor design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Cursor design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Dashboard design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Dashboard design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Dashboard design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Neutral Modern design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Neutral Modern design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Neutral Modern design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Discord design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Discord design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Discord design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Dithered design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Dithered design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Dithered design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Doodle design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Doodle design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Doodle design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Dramatic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Dramatic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Dramatic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Duolingo design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Duolingo design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Duolingo design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Editorial design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Editorial design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Editorial design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Elegant design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Elegant design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Elegant design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the ElevenLabs design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the ElevenLabs design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the ElevenLabs design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Energetic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Energetic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Energetic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Enterprise design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Enterprise design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Enterprise design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Expo design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Expo design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Expo design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Expressive design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Expressive design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Expressive design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Fantasy design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Fantasy design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Fantasy design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Ferrari design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Ferrari design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Ferrari design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Figma design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Figma design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Figma design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Flat design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Flat design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Flat design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Framer design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Framer design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Framer design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Friendly design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Friendly design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Friendly design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Futuristic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Futuristic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Futuristic design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the GitHub design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the GitHub design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the GitHub design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Glassmorphism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Glassmorphism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Glassmorphism design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Gradient design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Gradient design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Gradient design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the HashiCorp design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the HashiCorp design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the HashiCorp design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Hugging Face design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Hugging Face design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Hugging Face design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the IBM design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the IBM design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the IBM design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Intercom design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Intercom design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Intercom design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the kami (紙 / 纸) design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Use this plugin for the following Chinese-language task: Generate a {{artifactKind}} using the kami (紙 / 纸) design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "Generate a {{artifactKind}} using the kami (紙 / 纸) design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Kraken design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Kraken design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Kraken design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Lamborghini design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Lamborghini design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Lamborghini design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

View file

@ -18,7 +18,10 @@
"scenario": "design",
"surface": "web",
"useCase": {
"query": "Generate a {{artifactKind}} using the Levels design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
"query": {
"en": "Generate a {{artifactKind}} using the Levels design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md.",
"zh-CN": "使用这个插件完成以下任务Generate a {{artifactKind}} using the Levels design system. Stay faithful to its colour palette, typography, spacing, iconography, and component vocabulary as documented in DESIGN.md."
}
},
"inputs": [
{

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