diff --git a/docs/core/plugins.md b/docs/core/plugins.md index 3ca33691c..ed3f1b600 100644 --- a/docs/core/plugins.md +++ b/docs/core/plugins.md @@ -22,10 +22,9 @@ actions: init: myPluginInit -setup: - handler: setupMyPlugin - references: generateMyPluginReferences - description: Configure API credentials +setup: setupMyPlugin +agentkit: generateMyPluginAgentKit +description: Configure API credentials ``` ### Fields @@ -41,7 +40,9 @@ setup: | `actions` | array? | Server action exports | | `routes` | array? | Plugin-provided pages (backoffice tools, admin dashboards) | | `init` | string? | Export name for `JayInit` constant (auto-discovers `lib/init.ts` if omitted) | -| `setup` | object? | Setup command configuration | +| `setup` | string? | Export name for setup handler (`jay-stack setup`) | +| `agentkit` | string? | Export name for agent-kit handler (`jay-stack agent-kit`) | +| `description` | string? | Human-readable setup description (top-level) | ### contracts @@ -121,10 +122,9 @@ CLI commands for admin and batch operations. Run via `jay-stack run /; - /** Plugin setup configuration (Design Log #87) */ - setup?: { - /** Export name (NPM) or relative path (local) to setup handler function */ - handler: string; - /** Export name for reference data generation (called by jay-stack agent-kit) */ - references?: string; - /** Human-readable description of what setup does */ - description?: string; - }; + /** Export name (NPM) or relative path (local) to setup handler — `jay-stack setup` */ + setup?: string; + /** Export name for agent-kit handler — `jay-stack agent-kit` (add-menu, references, skills) */ + agentkit?: string; + /** Human-readable description of what setup validates or configures */ + description?: string; } /** diff --git a/packages/jay-stack/plugin-validator/lib/add-menu-catalog-lint.ts b/packages/jay-stack/plugin-validator/lib/add-menu-catalog-lint.ts new file mode 100644 index 000000000..6586265dc --- /dev/null +++ b/packages/jay-stack/plugin-validator/lib/add-menu-catalog-lint.ts @@ -0,0 +1,654 @@ +/** + * Add Menu catalog schema validation and lint. + * + * Source of truth for plugin authors (`jay-stack validate-plugin`) and for + * AIditor runtime catalog loading. When changing Add Menu schema or lint rules, + * update this module and `agent-kit/plugin/aiditor-add-menu.md` in the same change. + */ + +export type AddMenuBrowseSize = 'large' | 'medium' | 'small'; + +export type AddMenuPresentation = + | { type: 'image'; src: string } + | { type: 'gif'; src: string; poster?: string } + | { type: 'html-fragment'; html: string }; + +export type AddMenuInteraction = { + mode: 'reference' | 'stage-place'; + stagePromptTemplate?: string; +}; + +export type AddMenuCatalogLintWarning = { + code: string; + message: string; + itemId?: string; + sourcePath?: string; +}; + +export type AddMenuValidationError = { + path: string; + message: string; + code?: string; +}; + +export type AddMenuItem = { + id: string; + title: string; + category: string; + prompt: string; + pluginName?: string; + packageName?: string; + subCategory?: string; + folderPath?: string[]; + thumbnail?: string; + presentation?: AddMenuPresentation; + browse?: { + size?: AddMenuBrowseSize; + }; + interaction?: AddMenuInteraction; +}; + +export type AddMenuCatalogFile = { + items: AddMenuItem[]; +}; + +const REJECTED_ITEM_FIELDS = ['kind', 'parameters', 'component', 'allowedScopes'] as const; +const HTML_SOFT_LIMIT_BYTES = 8 * 1024; +const HTML_HARD_LIMIT_BYTES = 32 * 1024; +const FOLDER_PATH_MAX_SEGMENTS = 32; + +const BLOCKED_TAGS = + /<\s*(script|iframe|object|embed)\b[^>]*>[\s\S]*?<\/\s*\1\s*>|<\s*(script|iframe|object|embed)\b[^>]*\/?>/gi; +const EVENT_HANDLER_ATTR = /\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi; +const JAVASCRIPT_URL = /\b(href|src|xlink:href)\s*=\s*("|')\s*javascript:/gi; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function byteLengthUtf8(value: string): number { + return new TextEncoder().encode(value).length; +} + +function detectUnsafeAddMenuHtmlFragment(html: string): { code: string; message: string } | null { + if (BLOCKED_TAGS.test(html)) { + BLOCKED_TAGS.lastIndex = 0; + return { + code: 'html-fragment-unsafe-markup', + message: 'html-fragment must not include script, iframe, object, or embed', + }; + } + BLOCKED_TAGS.lastIndex = 0; + + if (EVENT_HANDLER_ATTR.test(html)) { + EVENT_HANDLER_ATTR.lastIndex = 0; + return { + code: 'html-fragment-unsafe-markup', + message: 'html-fragment must not include inline event handler attributes', + }; + } + EVENT_HANDLER_ATTR.lastIndex = 0; + + if (JAVASCRIPT_URL.test(html)) { + JAVASCRIPT_URL.lastIndex = 0; + return { + code: 'html-fragment-unsafe-markup', + message: 'html-fragment must not include javascript: URLs', + }; + } + JAVASCRIPT_URL.lastIndex = 0; + + return null; +} + +function sanitizeAddMenuHtmlFragment(html: string): string { + let result = html; + result = result.replace(BLOCKED_TAGS, ''); + BLOCKED_TAGS.lastIndex = 0; + result = result.replace(EVENT_HANDLER_ATTR, ''); + EVENT_HANDLER_ATTR.lastIndex = 0; + result = result.replace(JAVASCRIPT_URL, ''); + JAVASCRIPT_URL.lastIndex = 0; + return result.trim(); +} + +function catalogWarning( + code: string, + message: string, + itemId?: string, + sourcePath?: string, +): AddMenuCatalogLintWarning { + return { code, message, ...(itemId ? { itemId } : {}), ...(sourcePath ? { sourcePath } : {}) }; +} + +function requiredString( + obj: Record, + field: string, + itemPath: string, + errors: AddMenuValidationError[], + code?: string, +): string | null { + const value = obj[field]; + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push({ + path: `${itemPath}.${field}`, + message: 'required non-empty string', + ...(code ? { code } : {}), + }); + return null; + } + return value.trim(); +} + +function optionalString(obj: Record, field: string): string | undefined { + const value = obj[field]; + if (value === undefined) return undefined; + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function validateInteraction( + raw: unknown, + itemPath: string, + errors: AddMenuValidationError[], +): AddMenuInteraction | undefined { + if (raw === undefined) return undefined; + if (!isRecord(raw)) { + errors.push({ + path: itemPath, + message: 'interaction must be an object', + code: 'interaction-not-object', + }); + return undefined; + } + + const mode = raw.mode; + if (mode !== 'reference' && mode !== 'stage-place') { + errors.push({ + path: `${itemPath}.mode`, + message: 'interaction.mode must be "reference" or "stage-place"', + code: 'interaction-invalid-mode', + }); + return undefined; + } + return { + mode, + stagePromptTemplate: optionalString(raw, 'stagePromptTemplate'), + }; +} + +function validatePresentation( + raw: unknown, + itemPath: string, + errors: AddMenuValidationError[], +): AddMenuPresentation | undefined { + if (raw === undefined) return undefined; + if (!isRecord(raw)) { + errors.push({ + path: itemPath, + message: 'presentation must be an object', + code: 'presentation-not-object', + }); + return undefined; + } + + const type = raw.type; + if (type !== 'image' && type !== 'gif' && type !== 'html-fragment') { + errors.push({ + path: `${itemPath}.type`, + message: 'presentation.type must be "image", "gif", or "html-fragment"', + code: 'presentation-unknown-type', + }); + return undefined; + } + + if (type === 'image') { + const src = requiredString(raw, 'src', itemPath, errors, 'presentation-missing-src'); + if (!src) return undefined; + return { type: 'image', src }; + } + + if (type === 'gif') { + const src = requiredString(raw, 'src', itemPath, errors, 'presentation-missing-src'); + if (!src) return undefined; + return { + type: 'gif', + src, + poster: optionalString(raw, 'poster'), + }; + } + + if ('src' in raw) { + errors.push({ + path: `${itemPath}.src`, + message: 'html-fragment must use presentation.html, not presentation.src', + code: 'html-fragment-src-not-allowed', + }); + } + + const htmlValue = raw.html; + if (typeof htmlValue !== 'string' || htmlValue.trim().length === 0) { + errors.push({ + path: `${itemPath}.html`, + message: 'html-fragment requires non-empty presentation.html', + code: 'html-fragment-missing-html', + }); + return undefined; + } + + const unsafe = detectUnsafeAddMenuHtmlFragment(htmlValue); + if (unsafe) { + errors.push({ + path: `${itemPath}.html`, + message: unsafe.message, + code: unsafe.code, + }); + return undefined; + } + + const sanitized = sanitizeAddMenuHtmlFragment(htmlValue); + if (byteLengthUtf8(sanitized) > HTML_HARD_LIMIT_BYTES) { + errors.push({ + path: `${itemPath}.html`, + message: `html-fragment exceeds ${HTML_HARD_LIMIT_BYTES} bytes after sanitize`, + code: 'html-fragment-oversize-hard', + }); + return undefined; + } + + return { type: 'html-fragment', html: htmlValue }; +} + +const BROWSE_SIZES: ReadonlySet = new Set(['large', 'medium', 'small']); + +function validateBrowse( + raw: unknown, + itemPath: string, + errors: AddMenuValidationError[], +): AddMenuItem['browse'] | undefined { + if (raw === undefined) return undefined; + if (!isRecord(raw)) { + errors.push({ + path: itemPath, + message: 'browse must be an object', + code: 'browse-not-object', + }); + return undefined; + } + + const sizeRaw = raw.size; + if (sizeRaw === undefined) { + return {}; + } + if (typeof sizeRaw !== 'string' || !BROWSE_SIZES.has(sizeRaw as AddMenuBrowseSize)) { + errors.push({ + path: `${itemPath}.size`, + message: 'browse.size must be "large", "medium", or "small"', + code: 'browse-unknown-size', + }); + return undefined; + } + + return { size: sizeRaw as AddMenuBrowseSize }; +} + +function validateFolderPath( + raw: unknown, + itemPath: string, + errors: AddMenuValidationError[], +): string[] | undefined { + if (raw === undefined) return undefined; + if (!Array.isArray(raw)) { + errors.push({ + path: `${itemPath}.folderPath`, + message: 'folderPath must be an array of strings', + code: 'folder-path-not-array', + }); + return undefined; + } + + const segments: string[] = []; + for (let index = 0; index < raw.length; index++) { + const value = raw[index]; + if (typeof value !== 'string') { + errors.push({ + path: `${itemPath}.folderPath[${index}]`, + message: 'folderPath segments must be strings', + code: 'folder-path-segment-type', + }); + return undefined; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + errors.push({ + path: `${itemPath}.folderPath[${index}]`, + message: 'folderPath segments must be non-empty', + code: 'folder-path-empty-segment', + }); + return undefined; + } + if (trimmed === '..' || trimmed.includes('/') || trimmed.includes('\\')) { + errors.push({ + path: `${itemPath}.folderPath[${index}]`, + message: 'folderPath segments must not contain ".." or path separators', + code: 'folder-path-invalid-segment', + }); + return undefined; + } + if (segments.at(-1) === trimmed) { + errors.push({ + path: `${itemPath}.folderPath[${index}]`, + message: 'folderPath must not contain duplicate adjacent segments', + code: 'folder-path-adjacent-duplicate', + }); + return undefined; + } + segments.push(trimmed); + } + + if (segments.length > FOLDER_PATH_MAX_SEGMENTS) { + errors.push({ + path: `${itemPath}.folderPath`, + message: `folderPath exceeds ${FOLDER_PATH_MAX_SEGMENTS} segments`, + code: 'folder-path-too-deep', + }); + return undefined; + } + + return segments.length > 0 ? segments : undefined; +} + +export function validateAddMenuItem( + raw: unknown, + itemPath: string, +): { item: AddMenuItem | null; errors: AddMenuValidationError[] } { + const errors: AddMenuValidationError[] = []; + if (!isRecord(raw)) { + return { + item: null, + errors: [ + { path: itemPath, message: 'item must be an object', code: 'item-not-object' }, + ], + }; + } + + for (const field of REJECTED_ITEM_FIELDS) { + if (field in raw) { + errors.push({ + path: `${itemPath}.${field}`, + message: `field "${field}" is not allowed in Add Menu catalog items`, + code: `rejected-field-${field}`, + }); + } + } + + const id = requiredString(raw, 'id', itemPath, errors, 'missing-id'); + const title = requiredString(raw, 'title', itemPath, errors, 'missing-title'); + const category = requiredString(raw, 'category', itemPath, errors, 'missing-category'); + const prompt = requiredString(raw, 'prompt', itemPath, errors, 'missing-prompt'); + if (errors.length > 0 || !id || !title || !category || !prompt) { + return { item: null, errors }; + } + + const interaction = validateInteraction(raw.interaction, `${itemPath}.interaction`, errors); + const presentation = validatePresentation(raw.presentation, `${itemPath}.presentation`, errors); + const browse = validateBrowse(raw.browse, `${itemPath}.browse`, errors); + const folderPath = validateFolderPath(raw.folderPath, itemPath, errors); + + if (errors.length > 0) { + return { item: null, errors }; + } + + return { + item: { + id, + title, + category, + prompt, + pluginName: optionalString(raw, 'pluginName'), + packageName: optionalString(raw, 'packageName'), + subCategory: optionalString(raw, 'subCategory'), + ...(folderPath ? { folderPath } : {}), + thumbnail: optionalString(raw, 'thumbnail'), + ...(presentation ? { presentation } : {}), + ...(browse ? { browse } : {}), + ...(interaction ? { interaction } : {}), + }, + errors, + }; +} + +export function validateAddMenuCatalogFile( + raw: unknown, + sourcePath: string, +): { file: AddMenuCatalogFile | null; errors: AddMenuValidationError[] } { + const errors: AddMenuValidationError[] = []; + if (!isRecord(raw)) { + return { + file: null, + errors: [ + { + path: sourcePath, + message: 'catalog file must be an object', + code: 'catalog-not-object', + }, + ], + }; + } + if (!Array.isArray(raw.items)) { + return { + file: null, + errors: [ + { + path: `${sourcePath}.items`, + message: 'items must be an array', + code: 'catalog-items-not-array', + }, + ], + }; + } + + const items: AddMenuItem[] = []; + raw.items.forEach((entry, index) => { + const result = validateAddMenuItem(entry, `${sourcePath}.items[${index}]`); + errors.push(...result.errors); + if (result.item) items.push(result.item); + }); + + if (items.length === 0 && errors.length > 0) { + return { file: null, errors }; + } + return { file: { items }, errors }; +} + +function normalizeAddMenuPresentation(item: AddMenuItem): AddMenuPresentation | undefined { + if (item.presentation) return item.presentation; + const thumbnail = item.thumbnail?.trim(); + if (!thumbnail) return undefined; + if (/\.gif$/i.test(thumbnail)) { + return { type: 'gif', src: thumbnail }; + } + return { type: 'image', src: thumbnail }; +} + +function normalizeAddMenuBrowseSize(item: AddMenuItem): AddMenuBrowseSize { + return item.browse?.size ?? 'medium'; +} + +function hasSingleRootDiv(html: string): boolean { + const trimmed = html.trim(); + if (!trimmed.startsWith(']*>/i); + if (!openMatch) return false; + const afterOpen = trimmed.slice(openMatch[0].length); + const closeIdx = afterOpen.lastIndexOf(''); + if (closeIdx < 0) return false; + const tail = afterOpen.slice(closeIdx + ''.length).trim(); + return tail.length === 0; +} + +function hasAtScopeStyle(html: string): boolean { + return /]*>[\s\S]*@scope\b/i.test(html); +} + +function lintHtmlFragment( + item: AddMenuItem, + sourcePath: string, +): { errors: AddMenuCatalogLintWarning[]; warnings: AddMenuCatalogLintWarning[] } { + const errors: AddMenuCatalogLintWarning[] = []; + const warnings: AddMenuCatalogLintWarning[] = []; + const presentation = item.presentation; + if (!presentation || presentation.type !== 'html-fragment') { + return { errors, warnings }; + } + + const rawHtml = presentation.html?.trim() ?? ''; + if (!rawHtml) { + errors.push( + catalogWarning( + 'html-fragment-missing-html', + 'html-fragment requires non-empty presentation.html', + item.id, + sourcePath, + ), + ); + return { errors, warnings }; + } + + const unsafe = detectUnsafeAddMenuHtmlFragment(rawHtml); + if (unsafe) { + errors.push(catalogWarning(unsafe.code, unsafe.message, item.id, sourcePath)); + return { errors, warnings }; + } + + const sanitized = sanitizeAddMenuHtmlFragment(rawHtml); + const bytes = byteLengthUtf8(sanitized); + if (bytes > HTML_HARD_LIMIT_BYTES) { + errors.push( + catalogWarning( + 'html-fragment-oversize-hard', + `html-fragment exceeds ${HTML_HARD_LIMIT_BYTES} bytes after sanitize (${bytes})`, + item.id, + sourcePath, + ), + ); + } else if (bytes > HTML_SOFT_LIMIT_BYTES) { + warnings.push( + catalogWarning( + 'html-fragment-oversize-soft', + `html-fragment exceeds ${HTML_SOFT_LIMIT_BYTES} bytes after sanitize (${bytes}) — consider trimming`, + item.id, + sourcePath, + ), + ); + } + + if (!hasSingleRootDiv(sanitized)) { + warnings.push( + catalogWarning( + 'html-fragment-missing-root-div', + 'html-fragment should use a single root
', + item.id, + sourcePath, + ), + ); + } + + if (!hasAtScopeStyle(sanitized)) { + warnings.push( + catalogWarning( + 'html-fragment-missing-at-scope', + 'html-fragment should include + +
+ prompt: ok +`), + SOURCE, + ); + expect(validated.errors).toEqual([]); + const linted = lintAddMenuCatalog(validated.file!.items, SOURCE); + expect(linted.errors).toEqual([]); + expect(linted.warnings).toEqual([]); + }); + + it('errors on unknown browse.size', () => { + const validated = validateAddMenuCatalogFile( + parseCatalog(` +items: + - id: test-plugin:bad-size + title: Bad size + category: Test + browse: + size: xlarge + prompt: ok +`), + SOURCE, + ); + expect(validated.file).toBeNull(); + expect(validated.errors.some((e) => e.code === 'browse-unknown-size')).toBe(true); + }); + + it('warns when large browse item has no presentation', () => { + const validated = validateAddMenuCatalogFile( + parseCatalog(` +items: + - id: test-plugin:large-no-preview + title: Large hero + category: Test + browse: + size: large + prompt: ok +`), + SOURCE, + ); + expect(validated.errors).toEqual([]); + const linted = lintAddMenuCatalog(validated.file!.items, SOURCE); + expect(linted.warnings.map((w) => w.code)).toEqual(['browse-large-without-presentation']); + }); + + it('rejects invalid interaction.mode', () => { + const validated = validateAddMenuCatalogFile( + parseCatalog(` +items: + - id: test-plugin:bad-interaction + title: Bad interaction + category: Test + prompt: ok + interaction: + mode: drag +`), + SOURCE, + ); + expect(validated.file).toBeNull(); + expect(validated.errors.some((e) => e.code === 'interaction-invalid-mode')).toBe(true); + }); + + it('accepts folderPath and interaction', () => { + const validated = validateAddMenuCatalogFile( + parseCatalog(` +items: + - id: test-plugin:folder-item + title: Nested asset + category: Media + prompt: ok + folderPath: + - Marketing + - Campaigns + interaction: + mode: reference +`), + SOURCE, + ); + expect(validated.errors).toEqual([]); + expect(validated.file?.items[0]?.folderPath).toEqual(['Marketing', 'Campaigns']); + }); + + it('rejects folderPath with path separators', () => { + const validated = validateAddMenuCatalogFile( + parseCatalog(` +items: + - id: test-plugin:bad-folder + title: Bad folder + category: Media + prompt: ok + folderPath: + - Marketing/Campaigns +`), + SOURCE, + ); + expect(validated.file).toBeNull(); + expect(validated.errors.some((e) => e.code === 'folder-path-invalid-segment')).toBe(true); + }); +}); + +describe('validateAddMenuCatalog (validate-plugin step)', () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + function makePluginWithCatalog(catalogYaml: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jay-add-menu-plugin-')); + tempDirs.push(dir); + fs.writeFileSync( + path.join(dir, 'plugin.yaml'), + 'name: add-menu-test-fixture\nsetup:\n handler: setup\n', + ); + const catalogRel = ADD_MENU_CATALOG_REL_PATHS[0]; + const catalogAbs = path.join(dir, catalogRel); + fs.mkdirSync(path.dirname(catalogAbs), { recursive: true }); + fs.writeFileSync(catalogAbs, catalogYaml); + return dir; + } + + it('skips when plugin has no catalog yaml', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'jay-add-menu-plugin-')); + tempDirs.push(dir); + fs.writeFileSync( + path.join(dir, 'plugin.yaml'), + 'name: no-catalog-fixture\nsetup:\n handler: setup\n', + ); + + const result: ValidationResult = { + valid: true, + errors: [], + warnings: [], + }; + await validateAddMenuCatalog( + { manifest: { name: 'no-catalog-fixture' }, pluginPath: dir, isNpmPackage: false }, + result, + ); + + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + }); + + it('maps lint findings into ValidationResult', async () => { + const pluginPath = makePluginWithCatalog(` +items: + - id: test-plugin:gif-no-poster + title: GIF without poster + category: Test + presentation: + type: gif + src: thumbnails/test/demo.gif + prompt: ok +`); + const result: ValidationResult = { + valid: true, + errors: [], + warnings: [], + }; + await validateAddMenuCatalog( + { + manifest: { + name: 'add-menu-test-fixture', + setup: 'setup', + agentkit: 'generateAgentKit', + }, + pluginPath, + isNpmPackage: false, + }, + result, + ); + + expect(result.errors).toEqual([]); + expect(result.warnings.map((w) => w.code)).toEqual(['gif-missing-poster']); + expect(result.warnings[0]?.suggestion).toMatch(/poster/); + }); + + it('warns when plugin ships catalog but has no agentkit references handler', async () => { + const pluginPath = makePluginWithCatalog(` +items: + - id: test-plugin:ok + title: OK + category: Test + prompt: ok +`); + const result: ValidationResult = { + valid: true, + errors: [], + warnings: [], + }; + await validateAddMenuCatalog( + { manifest: { name: 'add-menu-test-fixture' }, pluginPath, isNpmPackage: false }, + result, + ); + + expect(result.warnings.map((w) => w.code)).toEqual(['add-menu-missing-agentkit-handler']); + expect(result.warnings[0]?.suggestion).toMatch(/agentkit/); + }); + + it('does not warn about agentkit handler when agentkit is declared', async () => { + const pluginPath = makePluginWithCatalog(` +items: + - id: test-plugin:ok + title: OK + category: Test + prompt: ok +`); + fs.writeFileSync( + path.join(pluginPath, 'plugin.yaml'), + 'name: add-menu-test-fixture\nsetup: setup\nagentkit: generateAgentKit\n', + ); + const result: ValidationResult = { + valid: true, + errors: [], + warnings: [], + }; + await validateAddMenuCatalog( + { + manifest: { + name: 'add-menu-test-fixture', + setup: 'setup', + agentkit: 'generateAgentKit', + }, + pluginPath, + isNpmPackage: false, + }, + result, + ); + + expect( + result.warnings.filter((w) => w.code === 'add-menu-missing-agentkit-handler'), + ).toEqual([]); + }); +}); diff --git a/packages/jay-stack/stack-cli/agent-kit-template/plugin/INSTRUCTIONS.md b/packages/jay-stack/stack-cli/agent-kit-template/plugin/INSTRUCTIONS.md index 73a891390..23caf05b1 100644 --- a/packages/jay-stack/stack-cli/agent-kit-template/plugin/INSTRUCTIONS.md +++ b/packages/jay-stack/stack-cli/agent-kit-template/plugin/INSTRUCTIONS.md @@ -13,8 +13,8 @@ A plugin provides headless components (data + interactions, no UI) that project 3. **Define actions** with `.jay-action` metadata 4. **Optionally add routes** — pages for admin tools and dashboards 5. **Optionally add validators** — custom jay-html validation rules -6. **Optionally add setup/references handlers** — config templating, add-menu generation -7. **Set up `plugin.yaml`** — list contracts, actions, services, contexts, routes, validators, setup +6. **Optionally add setup/agentkit handlers** — config templating, add-menu generation +7. **Set up `plugin.yaml`** — list contracts, actions, services, contexts, routes, validators, setup, agentkit 8. **Configure build** — dual entry points (server + client), vite.config.ts, package.json exports 9. **Validate** with `jay-stack validate-plugin` @@ -25,8 +25,8 @@ The plugin participates in four CLI commands, each running different hooks: | Command | When | What runs from your plugin | | --------------------------- | ------------------ | ---------------------------------------------------------------------------------- | | `jay-stack validate-plugin` | Plugin development | Checks plugin.yaml structure, contracts, exports, handler references | -| `jay-stack setup ` | Project setup | `setup.handler` — creates config files, validates credentials | -| `jay-stack agent-kit` | Before development | `setup.references` — generates add-menu items, reference data | +| `jay-stack setup ` | Project setup | `setup` — creates config files, validates credentials | +| `jay-stack agent-kit` | Before development | `agentkit` — generates add-menu items, reference data, skills, thumbnails | | `jay-stack validate` | During development | `validators[].handler` — runs your validation rules against project jay-html files | **`validate-plugin`** validates YOUR plugin's structure. Run it during plugin development. diff --git a/packages/jay-stack/stack-cli/agent-kit-template/plugin/plugin-structure.md b/packages/jay-stack/stack-cli/agent-kit-template/plugin/plugin-structure.md index 3f0838849..4243644ba 100644 --- a/packages/jay-stack/stack-cli/agent-kit-template/plugin/plugin-structure.md +++ b/packages/jay-stack/stack-cli/agent-kit-template/plugin/plugin-structure.md @@ -66,12 +66,9 @@ validators: handler: validateMediaOptimization description: Ensures media URLs use resize parameters -setup: - handler: setup-handler - references: references-handler - configTemplate: - - source: templates/config.yaml - target: my-plugin.yaml +setup: setup-handler +agentkit: agentkit-handler +description: Configure My Plugin ``` ### Contract Entry Fields @@ -201,16 +198,16 @@ Commands are CLI operations run via `jay-stack run`. Use `makeCliCommand()` to c Validators run during `jay-stack validate` against every parsed jay-html file in the project. See [validation.md](validation.md) for implementation details. -### Setup Fields +### Setup and agent-kit fields -- `handler` — Export name (NPM) or relative path (local) for `jay-stack setup `. Creates config files, validates credentials and services. -- `references` — Export name (NPM) or relative path (local) for `jay-stack agent-kit`. Generates discovery data: add-menu items, reference files. -- `description` — (optional) What this setup does +- `setup` — Export name (NPM) or relative path (local) for `jay-stack setup `. Creates config files, validates credentials and services. +- `agentkit` — Export name (NPM) or relative path (local) for `jay-stack agent-kit`. Generates discovery data: add-menu catalogs, reference files, skills, thumbnails. +- `description` — (optional, top-level) Human-readable description of what setup validates -**NPM plugins:** `handler` and `references` are export names from the package entry point. +**NPM plugins:** `setup` and `agentkit` are export names from the package entry point. **Local plugins:** relative paths to the handler modules. -`jay-stack validate-plugin` checks that these handlers exist and are correctly exported. +`jay-stack validate-plugin` checks that declared handlers exist and are correctly exported. See [setup-guide.md](setup-guide.md) for implementation details. diff --git a/packages/jay-stack/stack-cli/agent-kit-template/plugin/setup-guide.md b/packages/jay-stack/stack-cli/agent-kit-template/plugin/setup-guide.md index 3054bceb2..7b5fc4447 100644 --- a/packages/jay-stack/stack-cli/agent-kit-template/plugin/setup-guide.md +++ b/packages/jay-stack/stack-cli/agent-kit-template/plugin/setup-guide.md @@ -1,36 +1,38 @@ -# Plugin Setup & References +# Plugin Setup & Agent-Kit Plugins can provide two hooks for project configuration and AI agent discovery: -- **Setup handler** — runs during `jay-stack setup `. Creates config files, validates credentials, copies AIditor assets. -- **References handler** — runs during `jay-stack agent-kit`. Generates discovery data (add-menu items, reference files) using live services. +- **Setup handler** (`setup` in `plugin.yaml`) — runs during `jay-stack setup `. Creates config files, validates credentials. +- **Agent-kit handler** (`agentkit` in `plugin.yaml`) — runs during `jay-stack agent-kit`. Generates discovery data (add-menu catalogs, reference files, skills, thumbnails) using live services when needed. ## When Each Runs ``` -jay-stack setup → setup.handler() -jay-stack agent-kit → setup.references() (after contract materialization) +jay-stack setup → setup handler (config + credentials) +jay-stack agent-kit → agentkit handler (after contract materialization) ``` -Setup runs once when a project first installs the plugin. Agent-kit runs whenever the developer regenerates the agent kit — it can use live services to produce fresh data. +Setup runs when a project configures the plugin. Agent-kit runs whenever the developer regenerates the agent kit — it can use live services to produce fresh data. ## Declaring in plugin.yaml ```yaml -setup: - handler: setupMyPlugin # export name (NPM) or ./path (local) - references: generateMyReferences # export name (NPM) or ./path (local) - description: Install My Plugin config and AIditor catalog +name: my-plugin +setup: setupMyPlugin # export name (NPM) or ./path (local) — optional +agentkit: generateMyAgentKit # export name (NPM) or ./path (local) — optional +description: Validate credentials and install config # optional, top-level ``` -**NPM plugins:** both values are export names from the package entry point (`lib/index.ts`). -**Local plugins:** relative paths to the handler modules. +**NPM plugins:** `setup` and `agentkit` are export names from the package entry point (`lib/index.ts`). +**Local plugins:** relative paths to handler modules (e.g. `agentkit: ./agentkit` — export `agentkit` or `default` from that module). -`jay-stack validate-plugin` checks that these exist and are correctly exported. +`jay-stack validate-plugin` checks that declared handlers exist and are correctly exported. ## Writing a Setup Handler -The setup handler creates config files and AIditor assets. It receives a `PluginSetupContext` and returns a `PluginSetupResult`. +The setup handler creates config files and validates services. It receives a `PluginSetupContext` and returns a `PluginSetupResult`. + +**Do not** write add-menu catalogs in setup — use the agent-kit handler. ```typescript import type { PluginSetupContext, PluginSetupResult } from '@jay-framework/stack-server-runtime'; @@ -43,22 +45,18 @@ export async function setupMyPlugin(ctx: PluginSetupContext): Promise 0 - ? 'My Plugin catalog installed.' - : 'My Plugin catalog already present (use --force to rewrite).', + message: 'My Plugin configured.', }; } ``` @@ -82,42 +80,42 @@ export async function setupMyPlugin(ctx: PluginSetupContext): Promise/` files, aiditor skills, thumbnails. It can use live services (database queries, API calls) to produce dynamic content. ```typescript import type { - PluginReferencesContext, - PluginReferencesResult, + PluginAgentKitContext, + PluginAgentKitResult, } from '@jay-framework/stack-server-runtime'; import fs from 'node:fs'; import path from 'node:path'; +import yaml from 'yaml'; -export async function generateMyReferences( - ctx: PluginReferencesContext, -): Promise { +export async function generateMyAgentKit( + ctx: PluginAgentKitContext, +): Promise { if (ctx.initError) { - return { referencesCreated: [], message: `Skipped: ${ctx.initError.message}` }; + return { agentKitCreated: [], message: `Skipped: ${ctx.initError.message}` }; } - // Example: generate add-menu items from live data const outputPath = path.join(ctx.projectRoot, 'agent-kit/aiditor/add-menu/my-plugin.yaml'); fs.mkdirSync(path.dirname(outputPath), { recursive: true }); const items = [ { id: 'my-plugin:feature-1', title: 'Feature 1', category: 'My Plugin', prompt: '...' }, ]; - fs.writeFileSync(outputPath, yaml.dump({ items }), 'utf-8'); + fs.writeFileSync(outputPath, yaml.stringify({ items }), 'utf-8'); return { - referencesCreated: ['agent-kit/aiditor/add-menu/my-plugin.yaml'], + agentKitCreated: ['agent-kit/aiditor/add-menu/my-plugin.yaml'], message: `Generated ${items.length} add-menu items`, }; } ``` -### PluginReferencesContext +### PluginAgentKitContext | Field | Type | Description | | --------------- | --------- | --------------------------------------------------------------- | @@ -128,25 +126,25 @@ export async function generateMyReferences( | `initError` | `Error?` | Present if plugin init failed | | `force` | `boolean` | Whether `--force` flag was passed | -### PluginReferencesResult +### PluginAgentKitResult -| Field | Type | Description | -| ------------------- | ---------- | ---------------------------------------- | -| `referencesCreated` | `string[]` | Files created (relative to project root) | -| `message` | `string?` | Human-readable status message | +| Field | Type | Description | +| ----------------- | ---------- | ---------------------------------------- | +| `agentKitCreated` | `string[]` | Files created (relative to project root) | +| `message` | `string?` | Human-readable status message | -## Setup vs References — When to Use Which +## Setup vs Agent-Kit — When to Use Which -| Use case | Handler | Why | -| -------------------------------------------------------------------- | ------------------ | --------------------------------------------------------------- | -| Copy static template files (add-menu catalog, skill guides) | `setup.handler` | Templates don't change — copy once | -| Generate data from live services (product catalogs, CMS schemas) | `setup.references` | Needs services initialized; regenerated on each `agent-kit` run | -| Validate credentials / API keys | `setup.handler` | Part of initial project configuration | -| Write AIditor add-menu from project-specific data (DESIGN.md tokens) | `setup.references` | Data comes from project files, not static templates | +| Use case | Hook | Why | +| -------------------------------------------------------------------- | ---------- | ----------------------------------------------------------- | +| Copy static add-menu template, skills, thumbnails | `agentkit` | Discovery data — regenerated on `jay-stack agent-kit` | +| Generate data from live services (product catalogs, CMS schemas) | `agentkit` | Needs services initialized; refreshed on each agent-kit run | +| Validate credentials / API keys | `setup` | Part of initial project configuration | +| Write AIditor add-menu from project-specific data (DESIGN.md tokens) | `agentkit` | Data comes from project files at agent-kit time | ## AIditor Add-Menu Items -Both handlers can write to `agent-kit/aiditor/add-menu/.yaml`. The AIditor discovers and loads all YAML files in this directory. +The agent-kit handler writes to `agent-kit/aiditor/add-menu/.yaml`. The AIditor discovers and loads all YAML files in this directory. Each item: @@ -163,15 +161,17 @@ items: Read agent-kit/designer/feature-name.md for usage guide. ``` +See `agent-kit/plugin/aiditor-add-menu.md` (installed by `jay-stack setup aiditor`) for the full contributor guide. + ## Exporting Handlers -For NPM plugins, export the handlers from the package entry point: +For NPM plugins, export handlers from the package entry point: ```typescript // lib/index.ts export { setupMyPlugin } from './setup.js'; -export { generateMyReferences } from './references.js'; +export { generateMyAgentKit } from './agentkit.js'; // ... other exports (components, actions, services) ``` -For local plugins, use relative paths in plugin.yaml instead of export names. +For local plugins, use relative paths in `plugin.yaml` and export `agentkit` or `default` from the handler module. diff --git a/packages/jay-stack/stack-cli/lib/run-agent-kit.ts b/packages/jay-stack/stack-cli/lib/run-agent-kit.ts index 2de3f1a45..0b4c74a2a 100644 --- a/packages/jay-stack/stack-cli/lib/run-agent-kit.ts +++ b/packages/jay-stack/stack-cli/lib/run-agent-kit.ts @@ -40,7 +40,7 @@ export async function runAgentKit(options: { await ensureAgentKitDocs(projectRoot, options.force, options.mode); await mergePluginAgentKitGuides(projectRoot, options.mode); if (options.references !== false) { - await generatePluginReferences(projectRoot, options, initErrors, viteServer); + await generatePluginAgentKit(projectRoot, options, initErrors, viteServer); } } } finally { @@ -291,17 +291,17 @@ async function mergePluginAgentKitGuides(projectRoot: string, mode?: string): Pr } } -async function generatePluginReferences( +async function generatePluginAgentKit( projectRoot: string, options: { plugin?: string; force?: boolean; verbose?: boolean }, initErrors: Map, viteServer?: Awaited>, ): Promise { - const { discoverPluginsWithReferences, executePluginReferences } = await import( + const { discoverPluginsWithAgentKit, executePluginAgentKit } = await import( '@jay-framework/stack-server-runtime' ); - const plugins = await discoverPluginsWithReferences({ + const plugins = await discoverPluginsWithAgentKit({ projectRoot, verbose: options.verbose, pluginFilter: options.plugin, @@ -311,38 +311,38 @@ async function generatePluginReferences( const logger = getLogger(); logger.important(''); - logger.important(chalk.bold('Generating plugin references...')); + logger.important(chalk.bold('Generating plugin agent-kit data...')); for (const plugin of plugins) { const pluginInitError = initErrors.get(plugin.name); if (pluginInitError) { logger.warn( chalk.yellow( - ` ${plugin.name}: references skipped — init failed: ${pluginInitError.message}`, + ` ${plugin.name}: agent-kit skipped — init failed: ${pluginInitError.message}`, ), ); continue; } try { - const result = await executePluginReferences(plugin, { + const result = await executePluginAgentKit(plugin, { projectRoot, force: options.force ?? false, viteServer, verbose: options.verbose, }); - if (result.referencesCreated.length > 0) { + if (result.agentKitCreated.length > 0) { logger.important(chalk.green(` ${plugin.name}:`)); - for (const ref of result.referencesCreated) { - logger.important(chalk.gray(` ${ref}`)); + for (const created of result.agentKitCreated) { + logger.important(chalk.gray(` ${created}`)); } if (result.message) { logger.important(chalk.gray(` ${result.message}`)); } } } catch (error: any) { - logger.warn(chalk.yellow(` ${plugin.name}: references skipped — ${error.message}`)); + logger.warn(chalk.yellow(` ${plugin.name}: agent-kit skipped — ${error.message}`)); } } } diff --git a/packages/jay-stack/stack-server-runtime/lib/plugin-setup.ts b/packages/jay-stack/stack-server-runtime/lib/plugin-setup.ts index 6ebd24c07..6c8208f42 100644 --- a/packages/jay-stack/stack-server-runtime/lib/plugin-setup.ts +++ b/packages/jay-stack/stack-server-runtime/lib/plugin-setup.ts @@ -1,19 +1,19 @@ /** - * Plugin Setup and References (Design Log #87) + * Plugin Setup and Agent-Kit (Design Log #87) * * Two separate concerns: * - **Setup** (jay-stack setup): Config creation + credential/service validation - * - **References** (jay-stack agent-kit): Generate discovery data using live services + * - **Agent-kit** (jay-stack agent-kit): Generate discovery data using live services * * Setup flow: - * 1. Scan plugins for `setup.handler` in plugin.yaml + * 1. Scan plugins for `setup` in plugin.yaml * 2. Run init for all plugins (dependency-ordered) * 3. For each target plugin: load setup handler → call it → report result * - * References flow (called by agent-kit after materializing contracts): - * 1. Scan plugins for `setup.references` in plugin.yaml + * Agent-kit flow (called by agent-kit after materializing contracts): + * 1. Scan plugins for `agentkit` in plugin.yaml * 2. Services are already initialized (agent-kit does this for contract materialization) - * 3. For each plugin: load references handler → call it → report result + * 3. For each plugin: load agent-kit handler → call it → report result */ import * as fs from 'node:fs'; @@ -62,14 +62,14 @@ export interface PluginSetupResult { export type PluginSetupHandler = (context: PluginSetupContext) => Promise; // ============================================================================ -// References Types (jay-stack agent-kit) +// Agent-kit Types (jay-stack agent-kit) // ============================================================================ /** - * Context passed to a plugin's references handler. + * Context passed to a plugin's agent-kit handler. * Services may or may not be initialized — check initError if your handler needs them. */ -export interface PluginReferencesContext { +export interface PluginAgentKitContext { /** Plugin name (from plugin.yaml) */ pluginName: string; /** Project root directory */ @@ -85,19 +85,19 @@ export interface PluginReferencesContext { } /** - * Result returned by a plugin's references handler. + * Result returned by a plugin's agent-kit handler. */ -export interface PluginReferencesResult { - /** Reference files created (relative to project root) */ - referencesCreated: string[]; +export interface PluginAgentKitResult { + /** Agent-kit output files created (relative to project root) */ + agentKitCreated: string[]; /** Human-readable status message */ message?: string; } -/** A plugin's references handler function signature. */ -export type PluginReferencesHandler = ( - context: PluginReferencesContext, -) => Promise; +/** A plugin's agent-kit handler function signature. */ +export type PluginAgentKitHandler = ( + context: PluginAgentKitContext, +) => Promise; // ============================================================================ // Shared plugin info @@ -124,9 +124,9 @@ export interface PluginWithSetup { } /** - * Information about a discovered plugin with a references handler. + * Information about a discovered plugin with an agent-kit handler. */ -export interface PluginWithReferences { +export interface PluginWithAgentKit { /** Plugin name from plugin.yaml */ name: string; /** Plugin path (directory containing plugin.yaml) */ @@ -135,8 +135,8 @@ export interface PluginWithReferences { packageName: string; /** Whether this is a local plugin */ isLocal: boolean; - /** References handler export name */ - referencesHandler: string; + /** Agent-kit handler export name */ + agentKitHandler: string; /** Dependencies from package.json (for ordering) */ dependencies: string[]; } @@ -146,7 +146,7 @@ export interface PluginWithReferences { // ============================================================================ /** - * Discovers all plugins that have a `setup.handler` in plugin.yaml. + * Discovers all plugins that have a `setup` handler in plugin.yaml. */ export async function discoverPluginsWithSetup(options: { projectRoot: string; @@ -165,7 +165,7 @@ export async function discoverPluginsWithSetup(options: { const pluginsWithSetup: PluginWithSetup[] = []; for (const [packageName, plugin] of allPlugins) { - if (!plugin.manifest.setup?.handler) continue; + if (!plugin.manifest.setup) continue; // Filter to specific plugin if requested if (pluginFilter && plugin.name !== pluginFilter && packageName !== pluginFilter) { @@ -177,8 +177,8 @@ export async function discoverPluginsWithSetup(options: { pluginPath: plugin.pluginPath, packageName: plugin.packageName, isLocal: plugin.isLocal, - setupHandler: plugin.manifest.setup.handler, - setupDescription: plugin.manifest.setup.description, + setupHandler: plugin.manifest.setup, + setupDescription: plugin.manifest.description, dependencies: plugin.dependencies, }); @@ -192,13 +192,13 @@ export async function discoverPluginsWithSetup(options: { } /** - * Discovers all plugins that have a `setup.references` in plugin.yaml. + * Discovers all plugins that have an `agentkit` handler in plugin.yaml. */ -export async function discoverPluginsWithReferences(options: { +export async function discoverPluginsWithAgentKit(options: { projectRoot: string; verbose?: boolean; pluginFilter?: string; -}): Promise { +}): Promise { const { projectRoot, verbose, pluginFilter } = options; const allPlugins = await scanPlugins({ @@ -208,30 +208,30 @@ export async function discoverPluginsWithReferences(options: { discoverTransitive: true, }); - const pluginsWithRefs: PluginWithReferences[] = []; + const pluginsWithAgentKit: PluginWithAgentKit[] = []; for (const [packageName, plugin] of allPlugins) { - if (!plugin.manifest.setup?.references) continue; + if (!plugin.manifest.agentkit) continue; if (pluginFilter && plugin.name !== pluginFilter && packageName !== pluginFilter) { continue; } - pluginsWithRefs.push({ + pluginsWithAgentKit.push({ name: plugin.name, pluginPath: plugin.pluginPath, packageName: plugin.packageName, isLocal: plugin.isLocal, - referencesHandler: plugin.manifest.setup.references, + agentKitHandler: plugin.manifest.agentkit, dependencies: plugin.dependencies, }); if (verbose) { - getLogger().info(`[AgentKit] Found plugin with references: ${plugin.name}`); + getLogger().info(`[AgentKit] Found plugin with agent-kit handler: ${plugin.name}`); } } - return sortByDependencies(pluginsWithRefs); + return sortByDependencies(pluginsWithAgentKit); } /** @@ -266,7 +266,7 @@ export async function executePluginSetup( verbose?: boolean; }, ): Promise { - const { projectRoot, configDir, force, initError, viteServer, verbose } = options; + const { projectRoot, configDir, force, initError, viteServer } = options; const context: PluginSetupContext = { pluginName: plugin.name, @@ -285,14 +285,14 @@ export async function executePluginSetup( } // ============================================================================ -// References Execution (jay-stack agent-kit) +// Agent-kit Execution (jay-stack agent-kit) // ============================================================================ /** - * Loads and executes a plugin's references handler. + * Loads and executes a plugin's agent-kit handler. */ -export async function executePluginReferences( - plugin: PluginWithReferences, +export async function executePluginAgentKit( + plugin: PluginWithAgentKit, options: { projectRoot: string; force: boolean; @@ -300,12 +300,12 @@ export async function executePluginReferences( viteServer?: ViteSSRLoader; verbose?: boolean; }, -): Promise { +): Promise { const { projectRoot, force, initError, viteServer } = options; const referencesDir = path.join(projectRoot, 'agent-kit', 'references', plugin.name); - const context: PluginReferencesContext = { + const context: PluginAgentKitContext = { pluginName: plugin.name, projectRoot, referencesDir, @@ -314,9 +314,9 @@ export async function executePluginReferences( force, }; - const handler = await loadHandler( + const handler = await loadHandler( plugin, - plugin.referencesHandler, + plugin.agentKitHandler, viteServer, ); @@ -348,6 +348,7 @@ async function loadHandler any>( // For local plugins, try common export names if (typeof module[handlerName] === 'function') return module[handlerName]; + if (typeof module.agentkit === 'function') return module.agentkit; if (typeof module.setup === 'function') return module.setup; if (typeof module.default === 'function') return module.default; diff --git a/packages/plugins/design-system-validator/lib/generate-add-menu.ts b/packages/plugins/design-system-validator/lib/generate-add-menu.ts index 7447a9b8f..387f2b87f 100644 --- a/packages/plugins/design-system-validator/lib/generate-add-menu.ts +++ b/packages/plugins/design-system-validator/lib/generate-add-menu.ts @@ -8,8 +8,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import type { - PluginReferencesContext, - PluginReferencesResult, + PluginAgentKitContext, + PluginAgentKitResult, } from '@jay-framework/stack-server-runtime'; import { parseDesignMd, type DesignTokens } from './parse-design-md.js'; import yaml from 'js-yaml'; @@ -232,13 +232,13 @@ function findAllDesignMdFiles(projectRoot: string): string[] { return files; } -export async function generateDesignSystemReferences( - ctx: PluginReferencesContext, -): Promise { +export async function generateDesignSystemAgentKit( + ctx: PluginAgentKitContext, +): Promise { const designMdFiles = findAllDesignMdFiles(ctx.projectRoot); if (designMdFiles.length === 0) { - return { referencesCreated: [], message: 'No DESIGN.md found in project' }; + return { agentKitCreated: [], message: 'No DESIGN.md found in project' }; } const allItems: AddMenuItem[] = []; @@ -270,7 +270,7 @@ export async function generateDesignSystemReferences( } if (allItems.length === 0) { - return { referencesCreated: [], message: 'DESIGN.md found but no tokens defined' }; + return { agentKitCreated: [], message: 'DESIGN.md found but no tokens defined' }; } const outputPath = path.join(ctx.projectRoot, ADD_MENU_OUTPUT_REL); @@ -278,7 +278,7 @@ export async function generateDesignSystemReferences( fs.writeFileSync(outputPath, yaml.dump({ items: allItems }, { lineWidth: 120 }), 'utf-8'); return { - referencesCreated: [ADD_MENU_OUTPUT_REL], + agentKitCreated: [ADD_MENU_OUTPUT_REL], message: `Generated ${allItems.length} add-menu items from ${designMdFiles.length} DESIGN.md file(s)`, }; } diff --git a/packages/plugins/design-system-validator/lib/index.ts b/packages/plugins/design-system-validator/lib/index.ts index 77e63678e..6658f7524 100644 --- a/packages/plugins/design-system-validator/lib/index.ts +++ b/packages/plugins/design-system-validator/lib/index.ts @@ -2,4 +2,4 @@ export { validateTokens } from './validators/design-tokens.js'; export { validateComponents } from './validators/design-components.js'; export { validateStructure } from './validators/design-structure.js'; export { validateContrast } from './validators/design-contrast.js'; -export { generateDesignSystemReferences } from './generate-add-menu.js'; +export { generateDesignSystemAgentKit } from './generate-add-menu.js'; diff --git a/packages/plugins/design-system-validator/plugin.yaml b/packages/plugins/design-system-validator/plugin.yaml index ef6b37055..44c066dfa 100644 --- a/packages/plugins/design-system-validator/plugin.yaml +++ b/packages/plugins/design-system-validator/plugin.yaml @@ -1,8 +1,7 @@ name: design-system-validator -setup: - references: generateDesignSystemReferences - description: Generate AIditor add-menu entries from DESIGN.md tokens +agentkit: generateDesignSystemAgentKit +description: Generate AIditor add-menu entries from DESIGN.md tokens validators: - name: design-tokens diff --git a/packages/plugins/design-system-validator/test/generate-add-menu.test.ts b/packages/plugins/design-system-validator/test/generate-add-menu.test.ts index e8e53816b..d7929a137 100644 --- a/packages/plugins/design-system-validator/test/generate-add-menu.test.ts +++ b/packages/plugins/design-system-validator/test/generate-add-menu.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { generateDesignSystemReferences } from '../lib/generate-add-menu.js'; -import type { PluginReferencesContext } from '@jay-framework/stack-server-runtime'; +import { generateDesignSystemAgentKit } from '../lib/generate-add-menu.js'; +import type { PluginAgentKitContext } from '@jay-framework/stack-server-runtime'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -14,7 +14,7 @@ function makeTempProject(designMdContent?: string): string { return dir; } -function makeContext(projectRoot: string): PluginReferencesContext { +function makeContext(projectRoot: string): PluginAgentKitContext { return { pluginName: 'design-system-validator', projectRoot, @@ -59,7 +59,7 @@ function readOutput(dir: string): any { return yaml.load(fs.readFileSync(outputPath, 'utf-8')) as any; } -describe('generateDesignSystemReferences', () => { +describe('generateDesignSystemAgentKit', () => { let tempDir: string; beforeEach(() => { @@ -72,16 +72,16 @@ describe('generateDesignSystemReferences', () => { it('generates add-menu YAML with individual items per token', async () => { const ctx = makeContext(tempDir); - const result = await generateDesignSystemReferences(ctx); + const result = await generateDesignSystemAgentKit(ctx); - expect(result.referencesCreated).toEqual(['agent-kit/aiditor/add-menu/design-system.yaml']); + expect(result.agentKitCreated).toEqual(['agent-kit/aiditor/add-menu/design-system.yaml']); const content = readOutput(tempDir); expect(content.items.length).toEqual(10); }); it('generates one item per color token with HTML preview', async () => { const ctx = makeContext(tempDir); - await generateDesignSystemReferences(ctx); + await generateDesignSystemAgentKit(ctx); const content = readOutput(tempDir); const primaryItem = content.items.find((i: any) => i.id === 'design-system:color-primary'); @@ -98,7 +98,7 @@ describe('generateDesignSystemReferences', () => { it('generates typography items with HTML preview', async () => { const ctx = makeContext(tempDir); - await generateDesignSystemReferences(ctx); + await generateDesignSystemAgentKit(ctx); const content = readOutput(tempDir); const bodyItem = content.items.find((i: any) => i.id === 'design-system:typography-body-md'); @@ -111,7 +111,7 @@ describe('generateDesignSystemReferences', () => { it('generates items for spacing, rounded, breakpoints, and animations', async () => { const ctx = makeContext(tempDir); - await generateDesignSystemReferences(ctx); + await generateDesignSystemAgentKit(ctx); const content = readOutput(tempDir); expect(content.items.find((i: any) => i.id === 'design-system:spacing-sm')).toBeDefined(); @@ -122,7 +122,7 @@ describe('generateDesignSystemReferences', () => { it('generates component items with resolved token values', async () => { const ctx = makeContext(tempDir); - await generateDesignSystemReferences(ctx); + await generateDesignSystemAgentKit(ctx); const content = readOutput(tempDir); const btnItem = content.items.find( @@ -141,7 +141,7 @@ describe('generateDesignSystemReferences', () => { it('uses DESIGN.md name as category', async () => { const ctx = makeContext(tempDir); - await generateDesignSystemReferences(ctx); + await generateDesignSystemAgentKit(ctx); const content = readOutput(tempDir); expect(content.items[0].category).toEqual('Test Design'); @@ -157,7 +157,7 @@ describe('generateDesignSystemReferences', () => { ); const ctx = makeContext(tempDir); - await generateDesignSystemReferences(ctx); + await generateDesignSystemAgentKit(ctx); const content = readOutput(tempDir); const accentItem = content.items.find((i: any) => i.id === 'design-system:color-accent'); @@ -168,9 +168,9 @@ describe('generateDesignSystemReferences', () => { it('returns empty when no DESIGN.md exists', async () => { const emptyDir = makeTempProject(); const ctx = makeContext(emptyDir); - const result = await generateDesignSystemReferences(ctx); + const result = await generateDesignSystemAgentKit(ctx); - expect(result.referencesCreated).toEqual([]); + expect(result.agentKitCreated).toEqual([]); fs.rmSync(emptyDir, { recursive: true, force: true }); }); }); diff --git a/packages/plugins/gemini-agent/plugin.yaml b/packages/plugins/gemini-agent/plugin.yaml index f6f094600..bd987098b 100644 --- a/packages/plugins/gemini-agent/plugin.yaml +++ b/packages/plugins/gemini-agent/plugin.yaml @@ -11,6 +11,5 @@ actions: - name: submitToolResults action: submit-tool-results.jay-action - name: getToolDescriptions -setup: - handler: setupGeminiAgent - description: Configure Gemini API key +setup: setupGeminiAgent +description: Configure Gemini API key diff --git a/packages/plugins/ui-kit/agent-kit/aiditor/add-menu.template.yaml b/packages/plugins/ui-kit/agent-kit/aiditor/add-menu.template.yaml index ef33d5ee7..083e094f5 100644 --- a/packages/plugins/ui-kit/agent-kit/aiditor/add-menu.template.yaml +++ b/packages/plugins/ui-kit/agent-kit/aiditor/add-menu.template.yaml @@ -20,7 +20,6 @@ items: thumbnail: thumbnails/ui-kit/scroll-carousel.svg interaction: mode: stage-place - persistOnPage: true stagePromptTemplate: | Add a scroll carousel at this marker location on the page. prompt: | @@ -58,7 +57,6 @@ items: thumbnail: thumbnails/ui-kit/word-split.svg interaction: mode: stage-place - persistOnPage: true stagePromptTemplate: | Add word-split text at this marker location on the page. prompt: | @@ -74,7 +72,6 @@ items: thumbnail: thumbnails/ui-kit/letter-split.svg interaction: mode: stage-place - persistOnPage: true stagePromptTemplate: | Add letter-split text at this marker location on the page. prompt: | @@ -90,7 +87,6 @@ items: thumbnail: thumbnails/ui-kit/spring-button-hover-motion.svg interaction: mode: stage-place - persistOnPage: true stagePromptTemplate: | Apply the spring hover effect to the button at this marker location. prompt: | @@ -109,7 +105,6 @@ items: thumbnail: thumbnails/ui-kit/sticky-header-scroll.svg interaction: mode: stage-place - persistOnPage: true stagePromptTemplate: | Apply the sticky header scroll morph effect to the header at this marker location. prompt: | diff --git a/packages/plugins/ui-kit/lib/add-menu/copy-aiditor-thumbnails.ts b/packages/plugins/ui-kit/lib/add-menu/copy-aiditor-thumbnails.ts index 042a449cd..28dca6acb 100644 --- a/packages/plugins/ui-kit/lib/add-menu/copy-aiditor-thumbnails.ts +++ b/packages/plugins/ui-kit/lib/add-menu/copy-aiditor-thumbnails.ts @@ -1,12 +1,12 @@ import * as fs from 'fs'; import * as path from 'path'; -import type { PluginSetupContext } from '@jay-framework/stack-server-runtime'; +import type { PluginAgentKitContext } from '@jay-framework/stack-server-runtime'; const PUBLIC_THUMBNAIL_ROOT = path.join('public', 'aiditor-add-menu-thumbnails'); /** Copy bundled Add Menu thumbnails into project public/ for dev-server static hosting. */ export function copyAiditorAddMenuThumbnails( - ctx: PluginSetupContext, + ctx: PluginAgentKitContext, resolvePackagePath: (relativePath: string) => string, pluginName: string, ): string[] { diff --git a/packages/plugins/ui-kit/lib/setup.ts b/packages/plugins/ui-kit/lib/agentkit.ts similarity index 76% rename from packages/plugins/ui-kit/lib/setup.ts rename to packages/plugins/ui-kit/lib/agentkit.ts index 7a7d1d216..a72f67b12 100644 --- a/packages/plugins/ui-kit/lib/setup.ts +++ b/packages/plugins/ui-kit/lib/agentkit.ts @@ -1,5 +1,5 @@ /** - * Setup handler for ui-kit plugin (Design Log #142). + * Agent-kit handler for ui-kit plugin (Design Log #142). * * Writes AIditor Add Menu catalog: agent-kit/aiditor/add-menu/ui-kit.yaml * Copies effect skill markdown into agent-kit/aiditor/skills/ui-kit/ @@ -8,7 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { fileURLToPath } from 'url'; -import type { PluginSetupContext, PluginSetupResult } from '@jay-framework/stack-server-runtime'; +import type { + PluginAgentKitContext, + PluginAgentKitResult, +} from '@jay-framework/stack-server-runtime'; import { copyAiditorAddMenuThumbnails } from './add-menu/copy-aiditor-thumbnails.js'; const ADD_MENU_OUTPUT_REL = 'agent-kit/aiditor/add-menu/ui-kit.yaml'; @@ -38,7 +41,7 @@ function resolveAddMenuTemplatePath(): string { return resolvePackageAgentKitPath('agent-kit/aiditor/add-menu.template.yaml'); } -function writeAiditorSkills(ctx: PluginSetupContext): string[] { +function writeAiditorSkills(ctx: PluginAgentKitContext): string[] { const created: string[] = []; for (const { sourceRel, outputRel } of AIDITOR_SKILL_COPIES) { @@ -59,7 +62,7 @@ function writeAiditorSkills(ctx: PluginSetupContext): string[] { return created; } -function writeAddMenuCatalog(ctx: PluginSetupContext): string | null { +function writeAddMenuCatalog(ctx: PluginAgentKitContext): string | null { const outputPath = path.join(ctx.projectRoot, ADD_MENU_OUTPUT_REL); if (fs.existsSync(outputPath) && !ctx.force) { @@ -75,30 +78,33 @@ function writeAddMenuCatalog(ctx: PluginSetupContext): string | null { return ADD_MENU_OUTPUT_REL; } -export async function setupUiKit(ctx: PluginSetupContext): Promise { +export async function generateUiKitAgentKit( + ctx: PluginAgentKitContext, +): Promise { if (ctx.initError) { return { - status: 'error', + agentKitCreated: [], message: `ui-kit initialization failed: ${ctx.initError.message}`, }; } - const configCreated: string[] = []; + const agentKitCreated: string[] = []; const addMenuCreated = writeAddMenuCatalog(ctx); if (addMenuCreated) { - configCreated.push(addMenuCreated); + agentKitCreated.push(addMenuCreated); } - configCreated.push(...writeAiditorSkills(ctx)); - configCreated.push(...copyAiditorAddMenuThumbnails(ctx, resolvePackageAgentKitPath, 'ui-kit')); + agentKitCreated.push(...writeAiditorSkills(ctx)); + agentKitCreated.push( + ...copyAiditorAddMenuThumbnails(ctx, resolvePackageAgentKitPath, 'ui-kit'), + ); const message = - configCreated.length > 0 - ? 'ui-kit Add Menu catalog and effect skills installed.' + agentKitCreated.length > 0 + ? 'ui-kit Add Menu catalog and effect skills generated.' : 'ui-kit Add Menu catalog already present (use --force to rewrite).'; return { - status: 'configured', + agentKitCreated, message, - ...(configCreated.length > 0 ? { configCreated } : {}), }; } diff --git a/packages/plugins/ui-kit/lib/index.ts b/packages/plugins/ui-kit/lib/index.ts index 665c1cbb3..d19a7528c 100644 --- a/packages/plugins/ui-kit/lib/index.ts +++ b/packages/plugins/ui-kit/lib/index.ts @@ -3,4 +3,4 @@ export { scrollCarousel } from './scroll-carousel'; export { clipboardCopy } from './clipboard-copy'; export { wordSplit } from './word-split'; export { letterSplit } from './letter-split'; -export { setupUiKit } from './setup'; +export { generateUiKitAgentKit } from './agentkit'; diff --git a/packages/plugins/ui-kit/plugin.yaml b/packages/plugins/ui-kit/plugin.yaml index 7583a83d1..a4d23ae67 100644 --- a/packages/plugins/ui-kit/plugin.yaml +++ b/packages/plugins/ui-kit/plugin.yaml @@ -25,6 +25,4 @@ contracts: component: letterSplit description: Splits text into a span per letter for individual letter styling -setup: - handler: setupUiKit - description: Install ui-kit Add Menu catalog for AIditor +agentkit: generateUiKitAgentKit diff --git a/packages/plugins/ui-kit/test/setup-add-menu.test.ts b/packages/plugins/ui-kit/test/agentkit-add-menu.test.ts similarity index 82% rename from packages/plugins/ui-kit/test/setup-add-menu.test.ts rename to packages/plugins/ui-kit/test/agentkit-add-menu.test.ts index e350877b8..442b1897c 100644 --- a/packages/plugins/ui-kit/test/setup-add-menu.test.ts +++ b/packages/plugins/ui-kit/test/agentkit-add-menu.test.ts @@ -5,8 +5,8 @@ import { tmpdir } from 'os'; import { join } from 'path'; import { load as loadYaml } from 'js-yaml'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { PluginSetupContext } from '@jay-framework/stack-server-runtime'; -import { setupUiKit } from '../lib/setup'; +import type { PluginAgentKitContext } from '@jay-framework/stack-server-runtime'; +import { generateUiKitAgentKit } from '../lib/agentkit'; // Canonical shape reference (no @jay-framework/aiditor import): // jay-aiditor/packages/aiditor/test/fixtures/add-menu/valid-item.yaml @@ -20,12 +20,12 @@ const STICKY_SKILL_OUTPUT_REL = 'agent-kit/aiditor/skills/ui-kit/sticky-header-s function makeCtx( projectRoot: string, - overrides: Partial = {}, -): PluginSetupContext { + overrides: Partial = {}, +): PluginAgentKitContext { return { pluginName: 'ui-kit', projectRoot, - configDir: join(projectRoot, 'config'), + referencesDir: join(projectRoot, 'agent-kit/references/ui-kit'), services: new Map(), force: false, ...overrides, @@ -49,11 +49,11 @@ function assertAddMenuCatalogShape(catalog: unknown): void { } } -describe('setupUiKit add-menu catalog (Design Log #142 U3)', () => { +describe('generateUiKitAgentKit add-menu catalog (Design Log #142 U3)', () => { let projectRoot: string; beforeEach(() => { - projectRoot = mkdtempSync(join(tmpdir(), 'ui-kit-setup-')); + projectRoot = mkdtempSync(join(tmpdir(), 'ui-kit-agentkit-')); mkdirSync(join(projectRoot, 'config'), { recursive: true }); }); @@ -62,10 +62,9 @@ describe('setupUiKit add-menu catalog (Design Log #142 U3)', () => { }); it('writes ui-kit.yaml with valid catalog items', async () => { - const result = await setupUiKit(makeCtx(projectRoot)); + const result = await generateUiKitAgentKit(makeCtx(projectRoot)); - expect(result.status).toBe('configured'); - expect(result.configCreated).toEqual( + expect(result.agentKitCreated).toEqual( expect.arrayContaining([ ADD_MENU_OUTPUT_REL, SPRING_SKILL_OUTPUT_REL, @@ -81,7 +80,7 @@ describe('setupUiKit add-menu catalog (Design Log #142 U3)', () => { }); it('writes effect skill markdown files for AIditor', async () => { - await setupUiKit(makeCtx(projectRoot)); + await generateUiKitAgentKit(makeCtx(projectRoot)); const springPath = join(projectRoot, SPRING_SKILL_OUTPUT_REL); expect(existsSync(springPath)).toBe(true); @@ -106,10 +105,9 @@ describe('setupUiKit add-menu catalog (Design Log #142 U3)', () => { writeFileSync(join(skillDir, 'spring-button-hover.md'), '# stale\n'); writeFileSync(join(skillDir, 'sticky-header-scroll.md'), '# stale\n'); - const result = await setupUiKit(makeCtx(projectRoot)); + const result = await generateUiKitAgentKit(makeCtx(projectRoot)); - expect(result.status).toBe('configured'); - expect(result.configCreated ?? []).not.toContain(ADD_MENU_OUTPUT_REL); + expect(result.agentKitCreated).not.toContain(ADD_MENU_OUTPUT_REL); const written = loadYaml(readFileSync(join(addMenuDir, 'ui-kit.yaml'), 'utf-8')); expect(written).toEqual({ items: [] }); @@ -127,10 +125,9 @@ describe('setupUiKit add-menu catalog (Design Log #142 U3)', () => { writeFileSync(join(skillDir, 'spring-button-hover.md'), '# stale\n'); writeFileSync(join(skillDir, 'sticky-header-scroll.md'), '# stale\n'); - const result = await setupUiKit(makeCtx(projectRoot, { force: true })); + const result = await generateUiKitAgentKit(makeCtx(projectRoot, { force: true })); - expect(result.status).toBe('configured'); - expect(result.configCreated).toEqual( + expect(result.agentKitCreated).toEqual( expect.arrayContaining([ ADD_MENU_OUTPUT_REL, SPRING_SKILL_OUTPUT_REL, @@ -149,11 +146,10 @@ describe('setupUiKit add-menu catalog (Design Log #142 U3)', () => { ); }); - it('copies Add Menu thumbnails into public/ on setup', async () => { - const result = await setupUiKit(makeCtx(projectRoot)); + it('copies Add Menu thumbnails into public/ on agent-kit', async () => { + const result = await generateUiKitAgentKit(makeCtx(projectRoot)); - expect(result.status).toBe('configured'); - expect(result.configCreated).toEqual( + expect(result.agentKitCreated).toEqual( expect.arrayContaining(['public/aiditor-add-menu-thumbnails/ui-kit/popover-menu.svg']), ); expect(