From 95b8c651e72a38305ef45212adeb38e945fac322 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 06:06:20 +0000 Subject: [PATCH 1/7] feat(config): AB4734 shadow nudge when explicit skills config leaves conventional skills/*/SKILL.md uncovered (RFC #63) --- packages/agent-bundle/src/config/discover.ts | 29 ++++++++--- packages/agent-bundle/src/config/validate.ts | 16 ++++++ .../tests/package-conventions.test.ts | 52 +++++++++++++++++++ 3 files changed, 90 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 1e295002b..fdfb24a1c 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -16,6 +16,13 @@ export interface DiscoveredAsset { export interface DiscoveredProject { assets?: DiscoveredAsset[]; + /** + * Conventional `skills//SKILL.md` documents that explicit `skills` + * configuration leaves uncovered — the confusable shadowed state surfaced + * by the AB4734 migration nudge. Absent when config is silent (the + * convention itself applies) or when every conventional skill is covered. + */ + shadowedConventionalSkills?: readonly string[]; skills: SkillDocument[]; } @@ -89,23 +96,31 @@ export const discoverProject = async ( const projectRoot = resolve(root); const rules = await readProjectIgnoreRules(projectRoot); const configuredSkills = config.skills; + const conventionalSources = (await fastGlob('skills/*/SKILL.md', { + absolute: true, + cwd: projectRoot, + dot: true, + followSymbolicLinks: false, + onlyFiles: true, + })).filter((source) => !isProjectPathIgnored(rules, projectRoot, source)); const sources = configuredSkills === undefined - ? await fastGlob('skills/*/SKILL.md', { - absolute: true, - cwd: projectRoot, - dot: true, - followSymbolicLinks: false, - onlyFiles: true, - }) + ? conventionalSources : (await Promise.all(configuredSkills.map((skill) => expandConfiguredSkill(projectRoot, skill)))).flat(); const skillDirs = [...new Set(sources .filter((source) => !isProjectPathIgnored(rules, projectRoot, source)) .map((source) => (basename(source) === 'SKILL.md' ? dirname(source) : source)))] .sort((left, right) => left.localeCompare(right)); + const coveredDirs = new Set(skillDirs); + const shadowedConventionalSkills = configuredSkills === undefined + ? [] + : conventionalSources + .filter((source) => !coveredDirs.has(dirname(source))) + .sort((left, right) => left.localeCompare(right)); return { assets: await discoverAssets(projectRoot, config.assets, rules), + ...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }), skills: await Promise.all( skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)), ), diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index d61a82768..6a4661e6d 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -936,6 +936,21 @@ const packageConventionShadowNudges = (loaded: LoadedConfig): Diagnostic[] => { return diagnostics; }; +/** + * AB4734: explicit `skills` configuration leaves a conventional + * `skills//SKILL.md` document uncovered, so the convention is silently + * shadowed — the skills-directory analogue of AB4731/AB4732/AB4733. + */ +const skillConventionShadowNudges = ( + loaded: LoadedConfig, + discovered: DiscoveredProject, +): Diagnostic[] => (discovered.shadowedConventionalSkills ?? []).map((source) => nudgeDiagnostic( + 'AB4734', + `${relativePosix(loaded.context.projectRoot, source)} is present but explicit skills configuration does not cover it; the conventional skill is shadowed.`, + source, + 'Optional: remove the explicit skills configuration to adopt the skills//SKILL.md convention, add the directory to skills, or remove it to silence this nudge.', +)); + const isRspackHatchValue = (value: unknown): boolean => typeof value === 'function' || isRecord(value); @@ -1035,6 +1050,7 @@ export const validateSource = ( diagnostics.push(...validateScripts(loaded, registry)); diagnostics.push(...validateTools(loaded)); diagnostics.push(...packageConventionShadowNudges(loaded)); + diagnostics.push(...skillConventionShadowNudges(loaded, discovered)); return diagnostics; }; diff --git a/packages/agent-bundle/tests/package-conventions.test.ts b/packages/agent-bundle/tests/package-conventions.test.ts index 05d08246f..1cf0bea02 100644 --- a/packages/agent-bundle/tests/package-conventions.test.ts +++ b/packages/agent-bundle/tests/package-conventions.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { afterEach, describe, expect, it } from '@rstest/core'; import { + discoverProject, normalizeProject, validateSource, type NormalizationTargetRegistry, @@ -425,6 +426,57 @@ describe('migration nudges (AB473x)', () => { expect(diagnostics).toEqual([]); }); + const skillMarkdown = (name: string): string => + `---\nname: ${name}\ndescription: The ${name} skill for nudge coverage.\n---\n\n# ${name}\n\nBody.\n`; + + const discoveredAndValidated = async ( + config: Omit, + files: Readonly>, + ) => { + const root = await projectRoot(files); + const loaded = loadedProject({ + ...config, + plugin: { name: 'review-tools', version: '1.0.0' }, + }, root); + const discovered = await discoverProject(root, loaded.config); + return { diagnostics: validateSource(loaded, discovered, registry), root }; + }; + + it('nudges AB4734 when explicit skills configuration shadows a conventional skill', async () => { + const { diagnostics, root } = await discoveredAndValidated( + { skills: ['skills/covered'] }, + { + 'skills/covered/SKILL.md': skillMarkdown('covered'), + 'skills/shadowed/SKILL.md': skillMarkdown('shadowed'), + }, + ); + expect(diagnostics).toEqual([{ + code: 'AB4734', + message: expect.stringContaining('skills/shadowed/SKILL.md'), + recovery: expect.stringContaining('Optional'), + severity: 'info', + sourcePath: `${root}/skills/shadowed/SKILL.md`, + }]); + }); + + it('stays silent when skills config is absent or covers every conventional skill', async () => { + const files = { + 'skills/one/SKILL.md': skillMarkdown('one'), + 'skills/two/SKILL.md': skillMarkdown('two'), + }; + const conventional = await discoveredAndValidated({}, files); + expect(conventional.diagnostics).toEqual([]); + + const globCovered = await discoveredAndValidated({ skills: ['skills/*'] }, files); + expect(globCovered.diagnostics).toEqual([]); + + const literalCovered = await discoveredAndValidated( + { skills: ['skills/one', 'skills/two/SKILL.md'] }, + files, + ); + expect(literalCovered.diagnostics).toEqual([]); + }); + it('never raises nudges above info severity', async () => { const { diagnostics } = await validated( { From 609c3943a0398e983d70d634384c464e5e7dcdcc Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 06:19:36 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(config):=20rendered=20skills=20?= =?UTF-8?q?=E2=80=94=20skills//SKILL.tsx=20compiles=20to=20SKILL.md?= =?UTF-8?q?=20at=20build=20via=20the=20minimal=20markdown=20renderer=20(RF?= =?UTF-8?q?C=20#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/agent-bundle/package.json | 4 +- .../agent-bundle/src/adapters/portable.ts | 9 + packages/agent-bundle/src/adapters/types.ts | 9 + packages/agent-bundle/src/config/discover.ts | 26 +- packages/agent-bundle/src/config/normalize.ts | 1 + .../src/config/render-markdown.ts | 260 ++++++++++++++++ .../agent-bundle/src/config/rendered-skill.ts | 131 ++++++++ packages/agent-bundle/src/config/skill.ts | 65 +++- packages/agent-bundle/src/config/validate.ts | 7 + packages/agent-bundle/src/core/types.ts | 6 + packages/agent-bundle/tests/config.test.ts | 3 + .../skills/deploy-checklist/SKILL.tsx | 48 +++ .../deploy-checklist/references/playbook.md | 3 + .../tests/rendered-skills.test.ts | 293 ++++++++++++++++++ pnpm-lock.yaml | 6 + 15 files changed, 858 insertions(+), 13 deletions(-) create mode 100644 packages/agent-bundle/src/config/render-markdown.ts create mode 100644 packages/agent-bundle/src/config/rendered-skill.ts create mode 100644 packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/SKILL.tsx create mode 100644 packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/references/playbook.md create mode 100644 packages/agent-bundle/tests/rendered-skills.test.ts diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index 38fb15d48..e63feb375 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -87,6 +87,8 @@ }, "devDependencies": { "@modelcontextprotocol/server": "2.0.0", - "@types/ws": "8.18.1" + "@types/react": "19.2.18", + "@types/ws": "8.18.1", + "react": "19.2.8" } } diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index cececcee1..98f24fe5b 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -240,6 +240,15 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { for (const skill of model.skills) { if (!hasPortableTarget(skill.targets)) continue; + if (skill.markdown !== undefined) { + // A rendered skill's SKILL.md is compiled from its component module. + entries.push({ + content: skill.markdown, + kind: 'write', + relativePath: `skills/${skill.name}/SKILL.md`, + sourceInputs: sourceInputs(skill.source), + }); + } for (const resource of skill.resources) { entries.push({ bytes: resource.bytes, diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 5117e1442..c3e4e0781 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -206,6 +206,15 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): } for (const skill of input.sharedCopyEntries === false ? [] : model.skills) { if (!isSelected(skill.targets)) continue; + if (skill.markdown !== undefined) { + // A rendered skill's SKILL.md is compiled from its component module. + entries.push({ + content: skill.markdown, + kind: 'write', + relativePath: `skills/${skill.name}/SKILL.md`, + sourceInputs: sourceInputs(skill.source), + }); + } for (const resource of skill.resources) { entries.push({ bytes: resource.bytes, diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index fdfb24a1c..e306294a9 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -5,8 +5,13 @@ import fastGlob from 'fast-glob'; import type { AgentBundleConfig } from '../core/types.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; +import { isRenderedSkillSourceName } from './rendered-skill.ts'; import { parseSkill, type SkillDocument } from './skill.ts'; +/** A skill directory is identified by SKILL.md or a rendered-skill source module. */ +const isSkillDocumentName = (name: string): boolean => + name === 'SKILL.md' || isRenderedSkillSourceName(name); + /** One discovered project-level asset file with its artifact destination under `assets/`. */ export interface DiscoveredAsset { readonly bytes: number; @@ -38,7 +43,7 @@ const expandConfiguredSkill = async (projectRoot: string, skill: string): Promis onlyFiles: false, }); return matches - .filter((match) => match.dirent.isDirectory() || match.name === 'SKILL.md') + .filter((match) => match.dirent.isDirectory() || isSkillDocumentName(match.name)) .map((match) => match.path); }; @@ -96,7 +101,7 @@ export const discoverProject = async ( const projectRoot = resolve(root); const rules = await readProjectIgnoreRules(projectRoot); const configuredSkills = config.skills; - const conventionalSources = (await fastGlob('skills/*/SKILL.md', { + const conventionalSources = (await fastGlob('skills/*/SKILL.{md,ts,tsx}', { absolute: true, cwd: projectRoot, dot: true, @@ -109,14 +114,19 @@ export const discoverProject = async ( : (await Promise.all(configuredSkills.map((skill) => expandConfiguredSkill(projectRoot, skill)))).flat(); const skillDirs = [...new Set(sources .filter((source) => !isProjectPathIgnored(rules, projectRoot, source)) - .map((source) => (basename(source) === 'SKILL.md' ? dirname(source) : source)))] + .map((source) => (isSkillDocumentName(basename(source)) ? dirname(source) : source)))] .sort((left, right) => left.localeCompare(right)); const coveredDirs = new Set(skillDirs); - const shadowedConventionalSkills = configuredSkills === undefined - ? [] - : conventionalSources - .filter((source) => !coveredDirs.has(dirname(source))) - .sort((left, right) => left.localeCompare(right)); + const shadowedByDir = new Map(); + if (configuredSkills !== undefined) { + for (const source of [...conventionalSources].sort((left, right) => left.localeCompare(right))) { + const skillDir = dirname(source); + if (!coveredDirs.has(skillDir) && !shadowedByDir.has(skillDir)) { + shadowedByDir.set(skillDir, source); + } + } + } + const shadowedConventionalSkills = [...shadowedByDir.values()]; return { assets: await discoverAssets(projectRoot, config.assets, rules), diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index cab329de8..80527b186 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -657,6 +657,7 @@ export const normalizeProject = async ( dir: skill.dir, frontmatter, id: `skill:${name}`, + ...(skill.rendered === true ? { markdown: skill.markdown } : {}), name, provenance: skillProvenance(loaded, skill.source), resources: skill.resources.map((resource) => ({ ...resource })), diff --git a/packages/agent-bundle/src/config/render-markdown.ts b/packages/agent-bundle/src/config/render-markdown.ts new file mode 100644 index 000000000..988c2edcf --- /dev/null +++ b/packages/agent-bundle/src/config/render-markdown.ts @@ -0,0 +1,260 @@ +/** + * The minimal honest React-element-tree → Markdown renderer behind rendered + * skills (`skills//SKILL.tsx`). It walks plain element objects (the + * shape React's automatic JSX runtime produces) without depending on React + * itself, resolves function components (sync or async), and hand-emits + * Markdown for a documented element subset. Anything outside the subset is a + * `MarkdownRenderError` naming the element — never a silent approximation. + * + * Supported elements: `h1`–`h6`, `p`, `ul`/`ol`/`li` (nested), `strong`/`b`, + * `em`/`i`, `code`, `pre` (fenced, `language-*` class), `blockquote`, `a`, + * `hr`, `br`, fragments, arrays, strings, and numbers. + */ + +const reactFragment = Symbol.for('react.fragment'); + +/** Resolution depth cap: a component chain deeper than this is a cycle. */ +const maxComponentDepth = 256; + +type ElementProps = Readonly> & { readonly children?: unknown }; + +interface ElementLike { + readonly props: ElementProps; + readonly type: unknown; +} + +export class MarkdownRenderError extends Error { + constructor(message: string) { + super(message); + this.name = 'MarkdownRenderError'; + } +} + +const isElementLike = (value: unknown): value is ElementLike => + typeof value === 'object' && + value !== null && + 'type' in value && + 'props' in value && + typeof (value as ElementLike).props === 'object' && + (value as ElementLike).props !== null; + +const isThenable = (value: unknown): value is PromiseLike => + typeof value === 'object' && value !== null && typeof (value as PromiseLike).then === 'function'; + +const componentName = (type: unknown): string => + typeof type === 'function' && type.name !== '' ? type.name : 'anonymous component'; + +/** Calls function components (awaiting async ones) until an intrinsic node remains. */ +const resolveNode = async (node: unknown, depth = 0): Promise => { + if (depth > maxComponentDepth) { + throw new MarkdownRenderError('Rendered skill component resolution exceeded the depth limit; check for a component rendering itself.'); + } + if (!isElementLike(node) || typeof node.type !== 'function') return node; + let rendered: unknown; + try { + rendered = (node.type as (props: ElementProps) => unknown)(node.props); + } catch (error) { + throw new MarkdownRenderError( + `Rendered skill component ${componentName(node.type)} threw: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return resolveNode(isThenable(rendered) ? await rendered : rendered, depth + 1); +}; + +const childrenOf = (element: ElementLike): unknown => element.props.children; + +/** Flattens arrays and fragments into one resolved child list. */ +const resolveChildren = async (node: unknown): Promise => { + const resolved = await resolveNode(node); + if (resolved === null || resolved === undefined || typeof resolved === 'boolean') return []; + if (Array.isArray(resolved)) { + const nested = await Promise.all(resolved.map((child) => resolveChildren(child))); + return nested.flat(); + } + if (isElementLike(resolved) && resolved.type === reactFragment) { + return resolveChildren(childrenOf(resolved)); + } + return [resolved]; +}; + +const unsupported = (tag: string): never => { + throw new MarkdownRenderError( + `Rendered skill content contains unsupported element <${tag}>; supported elements are h1-h6, p, ul, ol, li, strong, b, em, i, code, pre, blockquote, a, hr, br, and fragments. Write the construct as plain Markdown text or hand-author SKILL.md instead.`, + ); +}; + +const headingLevels: Readonly> = Object.freeze({ + h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6, +}); + +const inlineTags = new Set(['a', 'b', 'br', 'code', 'em', 'i', 'strong']); + +const flattenTextChildren = async (node: unknown, tag: string): Promise => { + const children = await resolveChildren(node); + let text = ''; + for (const child of children) { + if (typeof child === 'string' || typeof child === 'number') { + text += String(child); + continue; + } + throw new MarkdownRenderError(`<${tag}> in rendered skill content may contain only text.`); + } + return text; +}; + +const renderInline = async (node: unknown): Promise => { + const children = await resolveChildren(node); + let text = ''; + for (const child of children) { + if (typeof child === 'string' || typeof child === 'number') { + text += String(child); + continue; + } + if (!isElementLike(child) || typeof child.type !== 'string') { + throw new MarkdownRenderError('Rendered skill content contains a value that is neither text nor a supported element.'); + } + const tag = child.type; + switch (tag) { + case 'strong': + case 'b': + text += `**${await renderInline(childrenOf(child))}**`; + break; + case 'em': + case 'i': + text += `*${await renderInline(childrenOf(child))}*`; + break; + case 'code': + text += `\`${await flattenTextChildren(childrenOf(child), 'code')}\``; + break; + case 'a': { + const href = child.props.href; + if (typeof href !== 'string' || href === '') { + throw new MarkdownRenderError(' in rendered skill content requires a nonempty string href.'); + } + text += `[${await renderInline(childrenOf(child))}](${href})`; + break; + } + case 'br': + text += ' \n'; + break; + default: + unsupported(tag); + } + } + return text; +}; + +const fenceLanguage = (codeElement: ElementLike): string => { + const className = codeElement.props.className; + if (typeof className !== 'string') return ''; + const match = /(?:^|\s)language-([\w+-]+)/u.exec(className); + return match?.[1] ?? ''; +}; + +const renderCodeBlock = async (pre: ElementLike): Promise => { + const children = await resolveChildren(childrenOf(pre)); + let language = ''; + let text = ''; + for (const child of children) { + if (typeof child === 'string' || typeof child === 'number') { + text += String(child); + continue; + } + if (isElementLike(child) && child.type === 'code') { + language = fenceLanguage(child); + text += await flattenTextChildren(childrenOf(child), 'code'); + continue; + } + throw new MarkdownRenderError('
 in rendered skill content may contain only text or one  element.');
+  }
+  const body = text.endsWith('\n') ? text.slice(0, -1) : text;
+  return `\`\`\`${language}\n${body}\n\`\`\``;
+};
+
+const indentContinuation = (text: string, indent: string): string =>
+  text.split('\n').map((line, index) => (index === 0 || line === '' ? line : `${indent}${line}`)).join('\n');
+
+const renderListItem = async (item: unknown, marker: string): Promise => {
+  if (!isElementLike(item) || item.type !== 'li') {
+    throw new MarkdownRenderError('
    and
      in rendered skill content may contain only
    1. elements.'); + } + const blocks = await renderBlocks(childrenOf(item)); + const indent = ' '.repeat(marker.length); + return `${marker}${indentContinuation(blocks.join('\n\n'), indent)}`; +}; + +const renderList = async (list: ElementLike): Promise => { + const items = await resolveChildren(childrenOf(list)); + const rendered = await Promise.all(items.map((item, index) => + renderListItem(item, list.type === 'ol' ? `${index + 1}. ` : '- '))); + if (rendered.length === 0) { + throw new MarkdownRenderError('
        and
          in rendered skill content require at least one
        1. .'); + } + return rendered.join('\n'); +}; + +const renderBlockElement = async (element: ElementLike): Promise => { + const tag = element.type as string; + const heading = headingLevels[tag]; + if (heading !== undefined) { + return `${'#'.repeat(heading)} ${await renderInline(childrenOf(element))}`; + } + switch (tag) { + case 'p': + return renderInline(childrenOf(element)); + case 'ul': + case 'ol': + return renderList(element); + case 'pre': + return renderCodeBlock(element); + case 'blockquote': { + const blocks = await renderBlocks(childrenOf(element)); + return blocks.join('\n\n').split('\n').map((line) => (line === '' ? '>' : `> ${line}`)).join('\n'); + } + case 'hr': + return '---'; + default: + return unsupported(tag); + } +}; + +/** Renders children as a sequence of Markdown blocks; loose inline content coalesces into paragraphs. */ +const renderBlocks = async (node: unknown): Promise => { + const children = await resolveChildren(node); + const blocks: string[] = []; + let paragraph = ''; + const flush = (): void => { + const trimmed = paragraph.trim(); + if (trimmed !== '') blocks.push(trimmed); + paragraph = ''; + }; + for (const child of children) { + if (typeof child === 'string' || typeof child === 'number') { + paragraph += String(child); + continue; + } + if (!isElementLike(child) || typeof child.type !== 'string') { + throw new MarkdownRenderError('Rendered skill content contains a value that is neither text nor a supported element.'); + } + if (inlineTags.has(child.type)) { + paragraph += await renderInline([child]); + continue; + } + flush(); + blocks.push(await renderBlockElement(child)); + } + flush(); + return blocks; +}; + +/** + * Renders one resolved element tree to a Markdown document body ending in a + * single trailing newline. Throws `MarkdownRenderError` outside the subset. + */ +export const renderElementToMarkdown = async (node: unknown): Promise => { + const blocks = await renderBlocks(node); + if (blocks.length === 0) { + throw new MarkdownRenderError('Rendered skill content produced no Markdown.'); + } + return `${blocks.join('\n\n')}\n`; +}; diff --git a/packages/agent-bundle/src/config/rendered-skill.ts b/packages/agent-bundle/src/config/rendered-skill.ts new file mode 100644 index 000000000..75d94b3a0 --- /dev/null +++ b/packages/agent-bundle/src/config/rendered-skill.ts @@ -0,0 +1,131 @@ +import { existsSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { createJiti } from 'jiti'; +import { stringify as stringifyYaml } from 'yaml'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { MarkdownRenderError, renderElementToMarkdown } from './render-markdown.ts'; + +/** + * The rendered-skill convention: `skills//SKILL.tsx` (or `.ts`) + * default-exports a component and exports a `frontmatter` record; the build + * compiles the rendered tree to the `SKILL.md` document every host consumes. + * A hand-authored `SKILL.md` in the same directory always wins (config beats + * convention; an authored file beats a generated one). + */ +const renderedSkillFileNames = ['SKILL.tsx', 'SKILL.ts'] as const; + +/** The source file behind a rendered skill in `skillDir`, when the convention applies. */ +export const renderedSkillSourceAt = (skillDir: string): string | undefined => { + for (const fileName of renderedSkillFileNames) { + const candidate = join(skillDir, fileName); + try { + if (existsSync(candidate) && statSync(candidate).isFile()) return candidate; + } catch { + // A racing deletion means the convention does not apply. + } + } + return undefined; +}; + +/** True for the conventional rendered-skill source file names. */ +export const isRenderedSkillSourceName = (fileName: string): boolean => + (renderedSkillFileNames as readonly string[]).includes(fileName); + +export interface CompiledRenderedSkill { + readonly body: string; + readonly frontmatter: Record; + /** The full compiled document: YAML frontmatter followed by the rendered body. */ + readonly markdown: string; +} + +export type RenderedSkillCompilation = + | { readonly document: CompiledRenderedSkill; readonly status: 'compiled' } + | { readonly diagnostic: Diagnostic; readonly status: 'failed' }; + +const failure = (code: string, message: string, sourcePath: string): RenderedSkillCompilation => ({ + diagnostic: { code, message, severity: 'error', sourcePath }, + status: 'failed', +}); + +const isPlainRecord = (value: unknown): value is Record => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +}; + +const describeError = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** + * Loads and compiles one rendered skill source to its Markdown document. The + * module executes through the same jiti pipeline that already runs consumer + * TypeScript at config-load time, with the automatic JSX runtime resolved + * from the consumer project. + */ +export const compileRenderedSkill = async (source: string): Promise => { + let moduleExports: Record; + try { + const jiti = createJiti(source, { + interopDefault: true, + jsx: { runtime: 'automatic' }, + moduleCache: false, + nativeModules: ['typescript'], + }); + moduleExports = await jiti.import>(source); + } catch (error) { + return failure('AB3003', `Rendered Skill module failed to load: ${describeError(error)}`, source); + } + + const component = moduleExports.default; + if (typeof component !== 'function') { + return failure( + 'AB3004', + 'Rendered Skill module must default-export a component function.', + source, + ); + } + const frontmatter = moduleExports.frontmatter; + if (!isPlainRecord(frontmatter)) { + return failure( + 'AB3004', + 'Rendered Skill module must export a `frontmatter` record with the skill name and description.', + source, + ); + } + + let body: string; + try { + body = await renderElementToMarkdown({ props: {}, type: component }); + } catch (error) { + return failure( + 'AB3005', + error instanceof MarkdownRenderError + ? error.message + : `Rendered Skill content failed to render: ${describeError(error)}`, + source, + ); + } + + let serializedFrontmatter: string; + try { + serializedFrontmatter = stringifyYaml(frontmatter); + } catch (error) { + return failure( + 'AB3004', + `Rendered Skill frontmatter is not serializable YAML: ${describeError(error)}`, + source, + ); + } + + const snapshot = structuredClone(frontmatter); + return { + document: { + body, + frontmatter: snapshot, + markdown: `---\n${serializedFrontmatter}---\n\n${body}`, + }, + status: 'compiled', + }; +}; diff --git a/packages/agent-bundle/src/config/skill.ts b/packages/agent-bundle/src/config/skill.ts index 1cc20df47..c804946c2 100644 --- a/packages/agent-bundle/src/config/skill.ts +++ b/packages/agent-bundle/src/config/skill.ts @@ -11,6 +11,11 @@ import { readProjectIgnoreRules, toPosixPath, } from './ignore.ts'; +import { + compileRenderedSkill, + isRenderedSkillSourceName, + renderedSkillSourceAt, +} from './rendered-skill.ts'; import { parseSkillMarkdown } from './skill-references.ts'; export interface SkillResource { @@ -26,6 +31,12 @@ export interface SkillDocument { frontmatter: Record; /** Exact authored/emitted Markdown; splitting remains server-owned. */ markdown: string; + /** + * Present when the document was compiled from the rendered-skill convention + * (`SKILL.tsx`/`SKILL.ts`): `source` names the component module, and + * `markdown` holds the compiled document the build must emit as SKILL.md. + */ + rendered?: true; resources: SkillResource[]; source: string; } @@ -64,7 +75,10 @@ const resourceList = async ( onlyFiles: true, }); const includedSources = sources.filter( - (source) => !isProjectPathIgnored(rules, root, source), + (source) => + !isProjectPathIgnored(rules, root, source) && + // The rendered-skill source files are build inputs, never shipped resources. + !isRenderedSkillSourceName(toPosixPath(relative(skillDir, source))), ); return Promise.all( @@ -96,6 +110,44 @@ const malformedFrontmatter = (source: string, error: unknown): Diagnostic => ({ sourcePath: source, }); +const renderedSourceShadowNudge = (source: string, renderedSource: string): Diagnostic => ({ + code: 'AB4735', + severity: 'info', + message: `${renderedSource} is present but the hand-authored SKILL.md wins; the rendered skill source is shadowed.`, + recovery: 'Optional: remove SKILL.md to adopt the rendered skill, or remove the component module to silence this nudge.', + sourcePath: source, +}); + +const parseRenderedSkill = async ( + dir: string, + renderedSource: string, + resources: SkillResource[], +): Promise => { + const compiled = await compileRenderedSkill(renderedSource); + if (compiled.status === 'failed') { + return { + body: '', + diagnostics: [compiled.diagnostic], + dir, + frontmatter: {}, + markdown: '', + rendered: true, + resources, + source: renderedSource, + }; + } + return { + body: compiled.document.body, + diagnostics: [], + dir, + frontmatter: compiled.document.frontmatter, + markdown: compiled.document.markdown, + rendered: true, + resources, + source: renderedSource, + }; +}; + export const parseSkill = async ( skillDir: string, projectRoot?: string, @@ -107,11 +159,15 @@ export const parseSkill = async ( const root = projectRoot === undefined ? await findProjectRoot(dir) : resolve(projectRoot); const rules = projectIgnoreRules ?? await readProjectIgnoreRules(root); const resources = await resourceList(dir, root, rules); + const renderedSource = renderedSkillSourceAt(dir); let markdown: string; try { markdown = await readFile(source, 'utf8'); } catch (error: unknown) { + if (renderedSource !== undefined && isErrno(error, 'ENOENT')) { + return parseRenderedSkill(dir, renderedSource, resources); + } return { body: '', diagnostics: [ @@ -130,11 +186,12 @@ export const parseSkill = async ( }; } + const shadowNudges = renderedSource === undefined ? [] : [renderedSourceShadowNudge(source, renderedSource)]; const parsed = parseSkillMarkdown(markdown); if (parsed.status === 'missing-frontmatter') { return { body: parsed.body, - diagnostics: [missingFrontmatter(source)], + diagnostics: [missingFrontmatter(source), ...shadowNudges], dir, frontmatter: {}, markdown, @@ -146,7 +203,7 @@ export const parseSkill = async ( if (parsed.status === 'valid') { return { body: parsed.body, - diagnostics: [], + diagnostics: [...shadowNudges], dir, frontmatter: parsed.frontmatter, markdown, @@ -157,7 +214,7 @@ export const parseSkill = async ( return { body: parsed.body, - diagnostics: [malformedFrontmatter(source, parsed.message)], + diagnostics: [malformedFrontmatter(source, parsed.message), ...shadowNudges], dir, frontmatter: {}, markdown, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 6a4661e6d..8e4fb5418 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -1242,6 +1242,13 @@ export const validateModel = ( }; for (const target of model.targets) { for (const skill of model.skills) { + if (skill.markdown !== undefined) { + recordOutput( + posix.join(target.name, 'skills', skill.name, 'SKILL.md'), + skill.source, + target.name, + ); + } for (const resource of skill.resources) { recordOutput( posix.join(target.name, 'skills', skill.name, resource.relativePath), diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 5b5105b76..14af61dbe 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -225,6 +225,12 @@ export interface NormalizedSkill { readonly dir: string; readonly frontmatter: Readonly>; readonly id: string; + /** + * The compiled SKILL.md document of a rendered skill (`SKILL.tsx` + * convention). When present, adapters emit it as a generated write entry; + * static skills ship their authored SKILL.md as a copied resource instead. + */ + readonly markdown?: string; readonly name: string; readonly provenance: SourceProvenance; readonly resources: readonly NormalizedSkillResource[]; diff --git a/packages/agent-bundle/tests/config.test.ts b/packages/agent-bundle/tests/config.test.ts index e05e48ea7..31eee1be8 100644 --- a/packages/agent-bundle/tests/config.test.ts +++ b/packages/agent-bundle/tests/config.test.ts @@ -87,6 +87,9 @@ it('honors an explicit empty skills list instead of conventional discovery', asy await expect(discoverProject(fixture.root, loaded.config)).resolves.toEqual({ assets: [], + // The conventional skill stays undiscovered but is reported as shadowed + // so validation can raise the AB4734 nudge. + shadowedConventionalSkills: [fixture.skillSource], skills: [], }); } finally { diff --git a/packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/SKILL.tsx b/packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/SKILL.tsx new file mode 100644 index 000000000..04bd94b32 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/SKILL.tsx @@ -0,0 +1,48 @@ +/** + * A rendered skill: the component tree below compiles to the SKILL.md this + * directory ships. The frontmatter export carries the document metadata. + */ + +interface StepProps { + readonly detail: string; + readonly title: string; +} + +const Step = ({ detail, title }: StepProps) => ( +
        2. + {title} — {detail} +
        3. +); + +export const frontmatter = { + description: 'Walks a release through the deploy checklist with rendered, data-driven steps.', + name: 'deploy-checklist', +}; + +const steps: readonly StepProps[] = [ + { detail: 'Green build on main.', title: 'CI' }, + { detail: 'Changelog covers every merged PR.', title: 'Notes' }, +]; + +export default function DeployChecklist() { + return ( + <> +

          Deploy checklist

          +

          + Run every step in order; the playbook in{' '} + references/playbook.md has the details. +

          +
            + {steps.map((step) => ( + + ))} +
          +
          +        {'agent-bundle build\n'}
          +      
          +
          +

          Ship only when the checklist is green.

          +
          + + ); +} diff --git a/packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/references/playbook.md b/packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/references/playbook.md new file mode 100644 index 000000000..b2d7f1503 --- /dev/null +++ b/packages/agent-bundle/tests/fixtures/rendered-skill/skills/deploy-checklist/references/playbook.md @@ -0,0 +1,3 @@ +# Deploy playbook + +The long-form companion the rendered checklist links to. diff --git a/packages/agent-bundle/tests/rendered-skills.test.ts b/packages/agent-bundle/tests/rendered-skills.test.ts new file mode 100644 index 000000000..1a2733560 --- /dev/null +++ b/packages/agent-bundle/tests/rendered-skills.test.ts @@ -0,0 +1,293 @@ +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { + discoverProject, + normalizeProject, + parseSkill, + validateSource, + type NormalizationTargetRegistry, +} from '../src/config/index.ts'; +import { compileRenderedSkill } from '../src/config/rendered-skill.ts'; +import { renderElementToMarkdown } from '../src/config/render-markdown.ts'; +import { standardPluginArtifactPlan } from '../src/adapters/types.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; +import type { LoadedConfig } from '../src/config/load.ts'; + +const fixtureRoot = join(import.meta.dirname, 'fixtures', 'rendered-skill'); + +const registry: NormalizationTargetRegistry = { + configExtensions: () => [], + defaultTargetNames: () => ['portable'], + has: (name) => ['portable', 'codex', 'claude'].includes(name), + supports: () => true, +}; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const projectRoot = async (files: Readonly>): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-rendered-skill-'))); + roots.push(root); + for (const [path, contents] of Object.entries(files)) { + const destination = join(root, path); + await mkdir(join(destination, '..'), { recursive: true }); + await writeFile(destination, contents); + } + return root; +}; + +const loadedProject = (config: AgentBundleConfig, root: string): LoadedConfig => ({ + config, + configPath: `${root}/agent-bundle.config.ts`, + context: { + command: 'build', + mode: 'production', + projectRoot: root, + selectedTargets: [], + }, +}); + +/** Element helper mirroring the plain object shape the automatic JSX runtime produces. */ +const element = (type: unknown, props: Record = {}): Record => + ({ props, type }); + +describe('renderElementToMarkdown', () => { + it('renders the supported block and inline subset', async () => { + const markdown = await renderElementToMarkdown([ + element('h2', { children: 'Steps' }), + 'Loose text with ', + element('code', { children: 'inline()' }), + ' calls.', + element('ul', { + children: [ + element('li', { children: ['First ', element('em', { children: 'gently' })] }), + element('li', { + children: [ + 'Second', + element('ul', { children: [element('li', { children: 'Nested detail' })] }), + ], + }), + ], + }), + element('hr'), + element('p', { children: ['Line one', element('br'), 'line two'] }), + ]); + + expect(markdown).toBe([ + '## Steps', + '', + 'Loose text with `inline()` calls.', + '', + '- First *gently*', + '- Second', + '', + ' - Nested detail', + '', + '---', + '', + 'Line one \nline two', + '', + ].join('\n')); + }); + + it('resolves sync and async function components', async () => { + const Title = () => element('h1', { children: 'Rendered' }); + const Body = async () => element('p', { children: 'From an async component.' }); + const markdown = await renderElementToMarkdown([element(Title), element(Body)]); + expect(markdown).toBe('# Rendered\n\nFrom an async component.\n'); + }); + + it('rejects unsupported elements by name', async () => { + await expect(renderElementToMarkdown(element('table'))).rejects.toThrow('unsupported element '); + await expect(renderElementToMarkdown(element('p', { children: element('img', { src: 'x.png' }) }))) + .rejects.toThrow('unsupported element '); + }); + + it('rejects an empty document and a self-rendering component', async () => { + await expect(renderElementToMarkdown([])).rejects.toThrow('produced no Markdown'); + const Loop = (): Record => element(Loop); + await expect(renderElementToMarkdown(element(Loop))).rejects.toThrow('depth limit'); + }); +}); + +describe('rendered skill compilation', () => { + const expectedBody = [ + '# Deploy checklist', + '', + 'Run every step *in order*; the playbook in [references/playbook.md](references/playbook.md) has the details.', + '', + '1. **CI** — Green build on main.', + '2. **Notes** — Changelog covers every merged PR.', + '', + '```sh', + 'agent-bundle build', + '```', + '', + '> Ship only when the checklist is green.', + '', + ].join('\n'); + + it('compiles the JSX fixture through jiti to the full SKILL.md document', async () => { + const compiled = await compileRenderedSkill( + join(fixtureRoot, 'skills', 'deploy-checklist', 'SKILL.tsx'), + ); + expect(compiled.status).toBe('compiled'); + if (compiled.status !== 'compiled') return; + expect(compiled.document.markdown).toBe([ + '---', + 'description: Walks a release through the deploy checklist with rendered, data-driven steps.', + 'name: deploy-checklist', + '---', + '', + expectedBody, + ].join('\n')); + }); + + it('parses a rendered skill directory: compiled body, module source, no source-file resource', async () => { + const skill = await parseSkill(join(fixtureRoot, 'skills', 'deploy-checklist'), fixtureRoot); + expect(skill.diagnostics).toEqual([]); + expect(skill.rendered).toBe(true); + expect(skill.source.endsWith('SKILL.tsx')).toBe(true); + expect(skill.body).toBe(expectedBody); + expect(skill.frontmatter).toEqual({ + description: 'Walks a release through the deploy checklist with rendered, data-driven steps.', + name: 'deploy-checklist', + }); + expect(skill.resources.map((resource) => resource.relativePath)).toEqual(['references/playbook.md']); + }); + + it('discovers rendered-only skill directories by convention and validates them cleanly', async () => { + const loaded = loadedProject({ plugin: { name: 'rendered', version: '1.0.0' } }, fixtureRoot); + const discovered = await discoverProject(fixtureRoot, loaded.config); + expect(discovered.skills.map((skill) => skill.frontmatter.name)).toEqual(['deploy-checklist']); + expect(validateSource(loaded, discovered, registry)).toEqual([]); + + const model = await normalizeProject(loaded, discovered, registry); + expect(model.skills[0]).toMatchObject({ + markdown: expect.stringContaining('# Deploy checklist'), + provenance: { kind: 'conventional' }, + }); + }); + + it('emits the compiled SKILL.md as a generated write entry in artifact plans', async () => { + const loaded = loadedProject({ plugin: { name: 'rendered', version: '1.0.0' } }, fixtureRoot); + const model = await normalizeProject(loaded, await discoverProject(fixtureRoot, loaded.config), registry); + const plan = standardPluginArtifactPlan({ + diagnostics: [], + hookDocumentValid: true, + hookEntries: [], + hookManifestPath: 'hooks/hooks.json', + isSelected: () => true, + marketplaceRelativePath: 'marketplace.json', + marketplaceValid: true, + mcpValid: true, + model, + plugin: {}, + pluginRelativePath: 'plugin.json', + targetName: 'portable', + }); + const skillEntries = plan.entries.filter((entry) => entry.relativePath.startsWith('skills/')); + expect(skillEntries).toEqual([ + expect.objectContaining({ + content: expect.stringContaining('# Deploy checklist'), + kind: 'write', + relativePath: 'skills/deploy-checklist/SKILL.md', + }), + expect.objectContaining({ + kind: 'copy', + relativePath: 'skills/deploy-checklist/references/playbook.md', + }), + ]); + }); + + it('loads rendered modules that build plain element objects without react', async () => { + const root = await projectRoot({ + 'skills/plain/SKILL.ts': [ + "export const frontmatter = { description: 'A plain rendered skill.', name: 'plain' };", + "const paragraph = { props: { children: 'Composed without JSX.' }, type: 'p' };", + "export default () => [{ props: { children: 'Plain' }, type: 'h1' }, paragraph];", + '', + ].join('\n'), + }); + const skill = await parseSkill(join(root, 'skills', 'plain'), root); + expect(skill.diagnostics).toEqual([]); + expect(skill.markdown).toBe([ + '---', + 'description: A plain rendered skill.', + 'name: plain', + '---', + '', + '# Plain', + '', + 'Composed without JSX.', + '', + ].join('\n')); + }); + + it('reports AB3003 for a module that fails to load', async () => { + const root = await projectRoot({ + 'skills/broken/SKILL.ts': "throw new Error('boom');\nexport default () => null;\n", + }); + const skill = await parseSkill(join(root, 'skills', 'broken'), root); + expect(skill.diagnostics).toEqual([expect.objectContaining({ code: 'AB3003', severity: 'error' })]); + }); + + it('reports AB3004 for a missing default component or frontmatter export', async () => { + const root = await projectRoot({ + 'skills/no-component/SKILL.ts': "export const frontmatter = { name: 'no-component' };\n", + 'skills/no-frontmatter/SKILL.ts': "export default () => ({ props: { children: 'x' }, type: 'p' });\n", + }); + const noComponent = await parseSkill(join(root, 'skills', 'no-component'), root); + expect(noComponent.diagnostics).toEqual([expect.objectContaining({ + code: 'AB3004', + message: expect.stringContaining('default-export a component'), + })]); + const noFrontmatter = await parseSkill(join(root, 'skills', 'no-frontmatter'), root); + expect(noFrontmatter.diagnostics).toEqual([expect.objectContaining({ + code: 'AB3004', + message: expect.stringContaining('frontmatter'), + })]); + }); + + it('reports AB3005 when the tree renders outside the supported subset', async () => { + const root = await projectRoot({ + 'skills/tabular/SKILL.ts': [ + "export const frontmatter = { description: 'Tables are unsupported.', name: 'tabular' };", + "export default () => ({ props: {}, type: 'table' });", + '', + ].join('\n'), + }); + const skill = await parseSkill(join(root, 'skills', 'tabular'), root); + expect(skill.diagnostics).toEqual([expect.objectContaining({ + code: 'AB3005', + message: expect.stringContaining('
          '), + })]); + }); + + it('nudges AB4735 when a hand-authored SKILL.md shadows the rendered source', async () => { + const root = await projectRoot({ + 'skills/both/SKILL.md': '---\nname: both\ndescription: The authored document wins.\n---\n\n# Both\n\nAuthored.\n', + 'skills/both/SKILL.tsx': [ + "export const frontmatter = { description: 'Never compiled.', name: 'both' };", + "export default () => ({ props: { children: 'Rendered' }, type: 'h1' });", + '', + ].join('\n'), + }); + const skill = await parseSkill(join(root, 'skills', 'both'), root); + expect(skill.rendered).toBeUndefined(); + expect(skill.body).toContain('Authored.'); + expect(skill.diagnostics).toEqual([expect.objectContaining({ + code: 'AB4735', + recovery: expect.stringContaining('Optional'), + severity: 'info', + })]); + expect(skill.resources.map((resource) => resource.relativePath)).toEqual(['SKILL.md']); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f38d538c0..e789a0a87 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -248,9 +248,15 @@ importers: specifier: 2.9.0 version: 2.9.0 devDependencies: + '@types/react': + specifier: 19.2.18 + version: 19.2.18 '@types/ws': specifier: 8.18.1 version: 8.18.1 + react: + specifier: 19.2.8 + version: 19.2.8 packages/create-agent-bundle: devDependencies: From 92d43c5a43a7cf9c8a169e68d8dee057afe5d310 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 06:21:10 +0000 Subject: [PATCH 3/7] refactor(create-agent-bundle): minimal template teaches the skills-directory convention (RFC #63) --- packages/create-agent-bundle/templates/minimal/README.md | 3 ++- .../templates/minimal/agent-bundle.config.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/create-agent-bundle/templates/minimal/README.md b/packages/create-agent-bundle/templates/minimal/README.md index be8ec9499..1c71c9b7e 100644 --- a/packages/create-agent-bundle/templates/minimal/README.md +++ b/packages/create-agent-bundle/templates/minimal/README.md @@ -16,7 +16,8 @@ npm run check # validate + build + typecheck + test - `agent-bundle.config.ts` — the one typed config. - `skills/getting-started/` — a Skill: `SKILL.md` frontmatter plus optional - `references/` and `assets/`. + `references/` and `assets/`. Every `skills//SKILL.md` directory is + discovered automatically; add a folder and it ships. - `tests/` — run with `npm run test`. ## The agent-bundle dependency diff --git a/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts b/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts index 9438c11b8..12d4d0ea9 100644 --- a/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts +++ b/packages/create-agent-bundle/templates/minimal/agent-bundle.config.ts @@ -1,11 +1,12 @@ import { defineConfig } from 'agent-bundle'; export default defineConfig({ + // No `skills` field needed: every `skills//SKILL.md` directory is + // discovered by convention. Declare `skills:` only to override the layout. plugin: { description: 'A skills-only agent plugin scaffolded from the minimal template.', name: 'my-agent-plugin', version: '0.1.0', }, - skills: ['skills/getting-started'], targets: ['portable', 'codex', 'claude'], }); From eb800febbac8559c16797a7f2627f5f215b44eb2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 31 Aug 2026 06:28:07 +0000 Subject: [PATCH 4/7] =?UTF-8?q?feat(rsc-runtime)!:=20defineRscApplication?= =?UTF-8?q?=20=E2=80=94=20flattened,=20JSX-free=20application;=20createRsc?= =?UTF-8?q?McpServer/runRscCli=20consume=20it;=20migrate=20audiobook-curat?= =?UTF-8?q?or=20to=20framework=20mode=20(RFC=20#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/audiobook-curator/README.md | 32 ++++--- .../audiobook-curator/agent-bundle.config.ts | 29 +++++- .../audiobook-curator/docs/parity-ledger.md | 2 +- examples/audiobook-curator/package.json | 2 +- .../src/{application.tsx => application.ts} | 48 +++------- examples/audiobook-curator/src/mcp-server.ts | 12 --- examples/audiobook-curator/src/mcp/curator.ts | 13 +++ .../tests/application.test.tsx | 19 ++-- .../tests/rendered-skills.test.ts | 2 +- packages/rsc-runtime/src/application.ts | 62 ++++++++++++ packages/rsc-runtime/src/cli.ts | 9 +- packages/rsc-runtime/src/mcp-server.ts | 16 ++-- packages/rsc-runtime/src/plugin.ts | 2 + .../rsc-runtime/tests/application.test.ts | 94 +++++++++++++++++++ .../rsc-runtime/tests/mcp-server-wire.test.ts | 17 ++-- packages/rsc-runtime/tests/plugin-app.test.ts | 8 +- 16 files changed, 272 insertions(+), 95 deletions(-) rename examples/audiobook-curator/src/{application.tsx => application.ts} (56%) delete mode 100644 examples/audiobook-curator/src/mcp-server.ts create mode 100644 examples/audiobook-curator/src/mcp/curator.ts create mode 100644 packages/rsc-runtime/src/application.ts create mode 100644 packages/rsc-runtime/tests/application.test.ts diff --git a/examples/audiobook-curator/README.md b/examples/audiobook-curator/README.md index 1aac54d78..6475f709a 100644 --- a/examples/audiobook-curator/README.md +++ b/examples/audiobook-curator/README.md @@ -6,11 +6,12 @@ From the repository root, launch this example with: pnpm example:audiobook ``` -A complete TypeScript recreation of the original `audiobook-curator`, authored -as one React Server Component plugin application. The same typed operation tree -produces a globally installable CLI, one stdio MCP server, one Skill, and native -Claude Code and Codex plugin artifacts. It has no hooks and does not call the old -Python curator. +A complete TypeScript recreation of the original `audiobook-curator`, built in +framework mode: `agent-bundle.config.ts` plus file conventions declare the +structure, and one typed operation catalog produces a globally installable +CLI, one stdio MCP server, one Skill, and native Claude Code and Codex plugin +artifacts. JSX appears only where something is rendered — the MCP result +receipts. It has no hooks and does not call the old Python curator. The package requires Node 22.19+, `ffprobe`, and `ffmpeg`. Optional features call the foreign tools that actually provide the evidence: Audiobook Forge, @@ -49,9 +50,12 @@ dependencies. ## Source layout -- `src/application.tsx` — composition only: merges the feature modules' - defaults and declares the `` tree (Skill, CLI Script, MCP - server, operations). +- `agent-bundle.config.ts` — the structure: plugin identity, targets, the CLI + script, and the MCP server (whose entry is the `src/mcp/curator.ts` + convention). The Skill needs no declaration at all: + `skills/curate-audiobooks/SKILL.md` ships by convention. +- `src/application.ts` — composition only: merges the feature modules' + defaults into one `defineRscApplication` operation catalog. - `src/operations/` — the operation catalog, grouped by workflow stage: `discovery` (inspect/inventory/library-audit/select), `audible` (search/select/cache), `evidence` (acoustic/whisper), `media-mutation` @@ -62,8 +66,8 @@ dependencies. `curator-core.ts`) over the shared `foundation.ts` and `media-process.ts` primitives; `result.tsx` renders every receipt for MCP. - `src/cli.ts` exports `main`; the framework's generated process envelope - turns it into both the bundled `