diff --git a/.changeset/cursor-rules-surface.md b/.changeset/cursor-rules-surface.md new file mode 100644 index 000000000..2e15a20b5 --- /dev/null +++ b/.changeset/cursor-rules-surface.md @@ -0,0 +1,6 @@ +--- +"agent-bundle": minor +--- + +Add conventional `rules/*.mdc` authoring with validated Cursor rule emission +and honest unavailable capability states for hosts without a rules surface. diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json index 45a78f9d5..5d69c05f5 100644 --- a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -53,6 +53,7 @@ "observedCliVersion": "2026-08-28", "plugin": { "manifest": ".cursor-plugin/plugin.json", + "rules": true, "skills": true, "localInstall": { "method": "copy", @@ -74,6 +75,7 @@ "Installed cursor-agent-exec loader candidates: .cursor-plugin/plugin.json, .claude-plugin/plugin.json, plugin.json.", "Installed loader substitutes CURSOR_PLUGIN_ROOT in MCP command, args, env, and cwd fields and in hook commands.", "Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local.", + "2026-08-31: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the rules component pointer; https://cursor.com/docs/plugins documents the rules component.", "The pinned Cursor hooks schema admits subagentStart, subagentStop, and workspaceOpen as first-class hook arrays; the public Cursor hooks reference documents workspaceOpen and subagent lifecycle payloads." ] } diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index d4bb783ea..7094d1d72 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -20,6 +20,7 @@ import { eventRouteCapabilitiesFrom, supportedEventRouteNamesFrom, supportedCapability, + unavailableCapability, } from './capability-state.ts'; import capabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import { @@ -511,6 +512,9 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude contract does not support both required modern MCP transports.', ), + rules: unavailableCapability( + 'The pinned Claude Code plugin contract (2.1.250) defines no rules component; project guidance ships through CLAUDE.md memory, not a rules directory.', + ), skills: capabilityStateFromSupport( capabilityTable.plugin.skills, evidence, diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index b845da9c7..f3ee253e8 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -423,6 +423,9 @@ export const codexAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Codex contract does not support both required modern MCP transports.', ), + rules: unavailableCapability( + 'The pinned Codex plugin contract (0.147.0) defines no rules component; Codex guidance remains outside the plugin component surface.', + ), skills: capabilityStateFromSupport( capabilityTable.plugin.skills, evidence, diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 6fbcf9636..57ec0e3e2 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -37,13 +37,16 @@ import mcpSchema from './schemas/cursor/mcp.schema.json' with { type: 'json' }; import pluginSchema from './schemas/cursor/plugin.schema.json' with { type: 'json' }; import { createDraft7AdapterValidator, + ruleWriteEntries, schemaDescriptorsFrom, + sortedEntries, standardArtifactLayout, standardPluginArtifactPlan, validateJsonSchemaDocument, validateModernMcpDocument, withPluginRootEnvAnchor, type TargetAdapter, + type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; @@ -233,6 +236,7 @@ export const planCursorMcpServer = ( export interface CursorManifestPointers { readonly hooks?: string; readonly mcp?: string; + readonly rules?: string; readonly skills?: string; readonly variables?: Record; } @@ -247,6 +251,7 @@ export const cursorManifest = ( ...(pointers.hooks === undefined ? {} : { hooks: pointers.hooks }), ...(pointers.mcp === undefined ? {} : { mcpServers: pointers.mcp }), name: model.metadata.name, + ...(pointers.rules === undefined ? {} : { rules: pointers.rules }), ...(pointers.skills === undefined ? {} : { skills: pointers.skills }), ...(pointers.variables === undefined ? {} : { variables: pointers.variables }), version: model.metadata.version, @@ -255,7 +260,7 @@ export const cursorManifest = ( const metadata = Object.freeze({ adapterRevision: '1.3.0', capabilityRevision: capabilityTable.observedCliVersion, - capabilitySha256: '20fc70ad5ba67d984826c3ac917fca66f28e61a8c74edb65dace53c29cc67279', + capabilitySha256: '20e93666770d97c0f5c1338de395f326c869684843b3b06bbd7ba3c45a0abc3e', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -312,8 +317,17 @@ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(cursorNam const mcpPlanContext: CursorMcpServerPlanContext = Object.freeze({ codePrefix: cursorName, errorDiagnostic }); +const artifactLayout: TargetArtifactLayout = Object.freeze({ + ...standardArtifactLayout, + rules: Object.freeze({ + allowedSuffixes: Object.freeze(['.mdc']), + directory: 'rules', + }), +}); + export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => { const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName); + const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets)); const diagnostics: Diagnostic[] = []; if (!isValidCursorPluginName(model.metadata.name)) { diagnostics.push(errorDiagnostic('cursor.name', cursorPluginNameError(model.metadata.name))); @@ -339,12 +353,14 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan const plugin = cursorManifest(model, { ...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}), ...(mcp !== undefined && mcpValid ? { mcp: `./${cursorArtifactPaths.mcp}` } : {}), + ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), ...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}), ...(variables === undefined ? {} : { variables }), }); diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors)); - return standardPluginArtifactPlan({ + const basePlan = standardPluginArtifactPlan({ + additionalPluginSourceInputs: selectedRules.map((rule) => rule.source), diagnostics, hookDocument, hookDocumentValid, @@ -361,11 +377,15 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan pluginRelativePath: cursorArtifactPaths.plugin, targetName: cursorName, }); + return Object.freeze({ + ...basePlan, + entries: sortedEntries([...basePlan.entries, ...ruleWriteEntries(model, isSelected)]), + }); }; export const cursorAdapter: TargetAdapter = Object.freeze({ artifactValidation, - artifactLayout: standardArtifactLayout, + artifactLayout, capabilities: Object.freeze({ ...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence), hooks: supportedCapability(evidence), @@ -375,6 +395,11 @@ export const cursorAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Cursor Plugin contract does not support both required modern MCP transports.', ), + rules: capabilityStateFromSupport( + capabilityTable.plugin.rules, + evidence, + 'The pinned Cursor Plugin contract does not support rules.', + ), skills: capabilityStateFromSupport( capabilityTable.plugin.skills, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 0cc1b92bf..a8e517474 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -35,6 +35,7 @@ import { type TargetHookContract, } from './hook-contract.ts'; import { + ruleWriteEntries, sortedEntries, sourceInputs, standardArtifactLayout, @@ -202,6 +203,7 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({ mcpApps: standardArtifactLayout.mcpApps, mcpEntries: standardArtifactLayout.mcpEntries, rootDocuments: Object.freeze(['AGENTS.md']), + rules: Object.freeze({ allowedSuffixes: Object.freeze(['.mdc']), directory: 'rules' }), scripts: standardArtifactLayout.scripts, skills: standardArtifactLayout.skills, }); @@ -211,6 +213,8 @@ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(pluginNam interface AgentsDocumentOptions { /** True when the Claude half of this bundle emitted `.lsp.json`. */ readonly lsp: boolean; + /** True when the Cursor half emitted conventional `.mdc` rules. */ + readonly rules: boolean; } const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions): string => { @@ -243,6 +247,11 @@ const agentsDocument = (model: NormalizedPlugin, options: AgentsDocumentOptions) '- `.lsp.json` — Claude Code language-server configuration (plugin-root convention). Claude Code only; Codex and Cursor have no LSP surface.', ] : []), + ...(options.rules + ? [ + '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', + ] + : []), '- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`.cursor.mjs`).', '- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.', '- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.', @@ -311,6 +320,8 @@ const cursorBundleHookContract = createCursorHookContract({ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { const diagnostics: Diagnostic[] = []; + const isSelected = (targets: readonly string[]): boolean => targets.includes(pluginName); + const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets)); // Host planners stay hook-free: the bundle lowers hooks once below, and // per-host nativeHooks passthrough remains with the host targets. const hookFreeModel: NormalizedPlugin = { ...model, hooks: [], nativeHooks: undefined }; @@ -386,6 +397,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { const manifest = cursorManifest(model, { ...(emitCursorHooks ? { hooks: `./${cursorPaths.hooks}` } : {}), ...(cursorMcp !== undefined && cursorMcpValid ? { mcp: `./${cursorPaths.mcp}` } : {}), + ...(selectedRules.length === 0 ? {} : { rules: './rules/' }), ...(model.skills.some((skill) => skill.targets.includes(pluginName)) ? { skills: './skills/' } : {}), ...(cursorManifestVariables === undefined ? {} : { variables: cursorManifestVariables }), }); @@ -396,7 +408,11 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { content: `${stableJson(manifest)}\n`, kind: 'write', relativePath: cursorPaths.plugin, - sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), + sourceInputs: sourceInputs( + model.metadata.provenance.sourcePath, + ...targetSourceInputs, + ...selectedRules.map((rule) => rule.source), + ), }); if (cursorMcp !== undefined && cursorMcpValid) { entries.push({ @@ -420,9 +436,11 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { } } + entries.push(...ruleWriteEntries(model, isSelected)); entries.push({ content: agentsDocument(model, { lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp), + rules: selectedRules.length > 0, }), kind: 'write', relativePath: 'AGENTS.md', @@ -467,6 +485,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ // supported: nothing about that document reaches Codex or Cursor. lsp: intersectCapabilityStates(claudeAdapter.capabilities.lsp!, codexAdapter.capabilities.lsp!), mcp: intersectCapabilityStates(claudeAdapter.capabilities.mcp!, codexAdapter.capabilities.mcp!), + // The bundle exposes Cursor's real rules directory, but Claude and Codex + // cannot consume it, so the composite row remains the honest intersection. + rules: intersectCapabilityStates(claudeAdapter.capabilities.rules!, codexAdapter.capabilities.rules!), skills: intersectCapabilityStates(claudeAdapter.capabilities.skills!, codexAdapter.capabilities.skills!), }), hookContract: bundleHookContract, diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index 23dbfafc4..d5c7195f1 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -335,6 +335,9 @@ export const portableAdapter: TargetAdapter = Object.freeze({ evidence, 'Agent Plugins 1.0.0 does not support both required modern MCP transports.', ), + rules: unavailableCapability( + 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no rules surface.', + ), skills: capabilityStateFromSupport( capabilityTable.plugin.skills, evidence, diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index b3705d6b7..e9e9b9747 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -188,6 +188,7 @@ const snapshotArtifactLayout = ( const mcpEntries = layout.mcpEntries === undefined ? undefined : snapshotOutputLayout(layout.mcpEntries, 'MCP entries'); + const rules = layout.rules === undefined ? undefined : snapshotOutputLayout(layout.rules, 'rules'); const scripts = layout.scripts === undefined ? undefined : snapshotOutputLayout(layout.scripts, 'scripts'); const assets = layout.assets === undefined ? undefined @@ -218,6 +219,7 @@ const snapshotArtifactLayout = ( ...(mcpApps === undefined ? {} : { mcpApps }), ...(mcpEntries === undefined ? {} : { mcpEntries }), ...(rootDocuments === undefined ? {} : { rootDocuments }), + ...(rules === undefined ? {} : { rules }), ...(scripts === undefined ? {} : { scripts }), ...(skills === undefined ? {} : { skills }), }); diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 8f0145017..f86088303 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -132,6 +132,19 @@ export const payloadCopyEntries = ( sourceInputs: sourceInputs(payload.provenance.sourcePath, file.source), }))); +/** Host-emitted write entries for rules selected by one target plan. */ +export const ruleWriteEntries = ( + model: NormalizedPlugin, + isSelected: (targets: readonly string[]) => boolean, +): TargetArtifactWrite[] => (model.rules ?? []) + .filter((rule) => isSelected(rule.targets)) + .map((rule) => ({ + content: rule.emittedMarkdown, + kind: 'write', + relativePath: `rules/${rule.name}.mdc`, + sourceInputs: sourceInputs(rule.source), + })); + /** One already-validated host-native document beyond the shared plugin set. */ export interface StandardPluginHostDocument { readonly document: Record; @@ -140,6 +153,8 @@ export interface StandardPluginHostDocument { } export interface StandardPluginArtifactsInput { + /** Additional authored inputs that select fields in the target plugin document. */ + readonly additionalPluginSourceInputs?: readonly string[]; readonly diagnostics: readonly Diagnostic[]; /** * Host-native documents a single target owns beyond the shared plugin, @@ -223,6 +238,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): ...hookSourceInputs, ...nativeHookSourceInputs, ...skillSourceInputs, + ...(input.additionalPluginSourceInputs ?? []), ), }]; if (mcp !== undefined && mcpValid) { @@ -382,6 +398,7 @@ export interface TargetArtifactLayout { readonly mcpEntries?: TargetArtifactOutputLayout; /** Adapter-owned plain documents at the artifact root (for example a generated AGENTS.md). */ readonly rootDocuments?: readonly string[]; + readonly rules?: TargetArtifactOutputLayout; readonly scripts?: TargetArtifactOutputLayout; readonly skills?: string; } diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 406a79d9c..5d66e4098 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -235,7 +235,7 @@ export type InspectionSkipReason = 'excluded-by-targets' | 'unsupported-capabili /** One component the plan silently omits for this target, with the intersection-rule cause. */ export interface InspectionSkippedComponent { readonly id: string; - readonly kind: 'hook' | 'mcp-app' | 'mcp-server' | 'script' | 'skill'; + readonly kind: 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill'; readonly name: string; readonly reason: InspectionSkipReason; } @@ -464,6 +464,7 @@ const inspectableComponents = (model: NormalizedPlugin): readonly InspectableCom ...model.hooks.map((hook) => ({ capability: 'hooks', id: hook.id, kind: 'hook' as const, name: hook.event, targets: hook.targets })), ...(model.mcpApps ?? []).map((app) => ({ capability: 'mcp', id: app.id, kind: 'mcp-app' as const, name: app.name, targets: app.targets })), ...model.mcpServers.map((server) => ({ capability: 'mcp', id: server.id, kind: 'mcp-server' as const, name: server.name, targets: server.targets })), + ...(model.rules ?? []).map((rule) => ({ capability: 'rules', id: rule.id, kind: 'rule' as const, name: rule.name, targets: rule.targets })), ...model.scripts.map((script) => ({ id: script.id, kind: 'script' as const, name: script.name, targets: script.targets })), ...model.skills.map((skill) => ({ capability: 'skills', id: skill.id, kind: 'skill' as const, name: skill.name, targets: skill.targets })), ]; diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index a85f27aa1..a8127b9df 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -423,6 +423,7 @@ const isTargetArtifactPath = ( isDirectOutputLayoutPath(relativePath, layout.hookWrappers) || isDirectOutputLayoutPath(relativePath, layout.mcpApps) || isDirectOutputLayoutPath(relativePath, layout.mcpEntries) || + isDirectOutputLayoutPath(relativePath, layout.rules) || isDirectOutputLayoutPath(relativePath, layout.scripts) || isSkillArtifactPath(relativePath, layout.skills) || isAdapterRootDocument(relativePath, layout.rootDocuments) || diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index 9923da6e2..dae5af1ba 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -10,6 +10,7 @@ import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts'; import type { CompiledRouteGraph } from '../routes/types.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; import { isRenderedSkillSourceName } from './rendered-skill.ts'; +import { parseRule, type RuleDocument } from './rule.ts'; import { parseSkill, type SkillDocument } from './skill.ts'; /** A skill directory is identified by SKILL.md or a rendered-skill source module. */ @@ -40,6 +41,8 @@ export interface DiscoveredPayload { export interface DiscoveredProject { assets?: DiscoveredAsset[]; payloads?: DiscoveredPayload[]; + /** Conventional flat `rules/*.mdc` documents; absent when none are discovered. */ + rules?: readonly RuleDocument[]; /** * The compiled conventional route graph (#93). Present only when route * discovery found modules or produced diagnostics, so route-free projects @@ -238,10 +241,21 @@ export const discoverProject = async ( const payloads = await discoverPayloads(projectRoot, config.payload); const routeGraph = await compileRouteGraph(projectRoot, config, rules); + const ruleSources = (await fastGlob('rules/*.mdc', { + absolute: true, + cwd: projectRoot, + dot: true, + followSymbolicLinks: false, + onlyFiles: true, + })) + .filter((source) => !isProjectPathIgnored(rules, projectRoot, source)) + .sort((left, right) => left.localeCompare(right)); + const discoveredRules = await Promise.all(ruleSources.map((source) => parseRule(source))); return { assets: await discoverAssets(projectRoot, config.assets, rules), ...(payloads.length === 0 ? {} : { payloads }), ...(routeGraph === undefined || isEmptyRouteGraph(routeGraph) ? {} : { routeGraph }), + ...(discoveredRules.length === 0 ? {} : { rules: discoveredRules }), ...(shadowedConventionalSkills.length === 0 ? {} : { shadowedConventionalSkills }), skills: await Promise.all( skillDirs.map((skillDir) => parseSkill(skillDir, projectRoot, rules)), diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 53a5083ac..6423962ae 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -11,6 +11,8 @@ export type { DiscoveredProject } from './discover.ts'; export { loadConfig } from './load.ts'; export type { LoadedConfig, LoadConfigOptions } from './load.ts'; export { normalizeProject } from './normalize.ts'; +export { parseRule } from './rule.ts'; +export type { RuleDocument } from './rule.ts'; export { parseSkill } from './skill.ts'; export type { SkillDocument, SkillResource } from './skill.ts'; export { defineSkill, Skill } from '../skills/define.ts'; @@ -47,6 +49,7 @@ export type { NormalizedPayloadFile, NormalizedPlugin, NormalizedRuntime, + NormalizedRule, NormalizedScript, NormalizedSkill, NormalizedSkillResource, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 141b7ad99..173a1b247 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -45,6 +45,7 @@ import type { NormalizedPayload, NormalizedPlugin, NormalizedRuntime, + NormalizedRule, NormalizedScript, NormalizedSkill, SourceProvenance, @@ -297,6 +298,7 @@ export const reservedPayloadDestinations = Object.freeze(new Set([ 'mcp-apps', 'mcp.json', 'plugin.json', + 'rules', 'scripts', 'skills', ])); @@ -902,6 +904,27 @@ const normalizeAssets = ( targets: [...targetNames], })); +const normalizeRules = ( + discovered: DiscoveredProject, + targetNames: readonly string[], +): readonly NormalizedRule[] => (discovered.rules ?? []).map((rule) => { + const name = basename(rule.source, extname(rule.source)); + const targets = rule.authoredTargets === undefined + ? [...targetNames] + : sortedUnique(rule.authoredTargets.filter((target) => targetNames.includes(target))); + return { + body: rule.body, + emittedMarkdown: rule.emittedMarkdown, + frontmatter: structuredClone(rule.frontmatter), + id: `rule:${name}`, + markdown: rule.markdown, + name, + provenance: { kind: 'conventional', sourcePath: rule.source }, + source: rule.source, + targets, + }; +}); + /** Selects the generated-executable floor; invalid raises fall back to the default the validator rejected. */ const normalizeRuntime = (loaded: LoadedConfig): NormalizedRuntime => { const node = loaded.config.runtime?.node; @@ -963,6 +986,7 @@ export const normalizeProject = async ( const mcpServers = normalizeMcpServers(loaded, discovered, targetNames, payloads); const scripts = normalizeScripts(loaded, discovered, targetNames); const assets = normalizeAssets(loaded, discovered, targetNames); + const rules = normalizeRules(discovered, targetNames); const packageBuild = normalizePackageBuild( loaded.config, loaded.context.projectRoot, @@ -988,6 +1012,7 @@ export const normalizeProject = async ( ...(nativeHooks.length === 0 ? {} : { nativeHooks }), ...(packageBuild === undefined ? {} : { packageBuild }), ...(payloads.length === 0 ? {} : { payloads }), + ...(rules.length === 0 ? {} : { rules }), runtime: normalizeRuntime(loaded), scripts, skills, diff --git a/packages/agent-bundle/src/config/rule.ts b/packages/agent-bundle/src/config/rule.ts new file mode 100644 index 000000000..1e69864ec --- /dev/null +++ b/packages/agent-bundle/src/config/rule.ts @@ -0,0 +1,165 @@ +import { readFile } from 'node:fs/promises'; + +import { stringify as stringifyYaml } from 'yaml'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { parseMarkdownFrontmatter } from './skill-references.ts'; + +export interface RuleDocument { + /** Peeled target restriction; never emitted in Cursor rule frontmatter. */ + readonly authoredTargets?: readonly string[]; + readonly body: string; + readonly diagnostics: readonly Diagnostic[]; + /** Host-emitted document with authoring-only frontmatter keys stripped. */ + readonly emittedMarkdown: string; + readonly frontmatter: Readonly>; + /** Exact authored `.mdc` bytes decoded as UTF-8; retained as an identity input. */ + readonly markdown: string; + readonly source: string; +} + +const diagnostic = ( + code: string, + message: string, + sourcePath: string, +): Diagnostic => ({ code, message, severity: 'error', sourcePath }); + +const nonemptyString = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +const allowedFields = new Set(['alwaysApply', 'description', 'globs', 'targets']); + +const validateFrontmatter = ( + declared: Readonly>, + source: string, +): { + readonly authoredTargets?: readonly string[]; + readonly diagnostics: readonly Diagnostic[]; + readonly frontmatter: Readonly>; +} => { + const diagnostics: Diagnostic[] = []; + const frontmatter: Record = {}; + + for (const field of Object.keys(declared).filter((field) => !allowedFields.has(field)).sort()) { + diagnostics.push(diagnostic( + 'AB4902', + `Rule frontmatter field ${JSON.stringify(field)} is not supported.`, + source, + )); + } + + const description = declared.description; + if (description !== undefined) { + if (typeof description === 'string') frontmatter.description = description; + else diagnostics.push(diagnostic('AB4903', 'Rule frontmatter description must be a string.', source)); + } + + const globs = declared.globs; + if (globs !== undefined) { + if (nonemptyString(globs)) { + frontmatter.globs = globs; + } else if (Array.isArray(globs) && globs.every(nonemptyString)) { + frontmatter.globs = [...globs]; + } else { + diagnostics.push(diagnostic( + 'AB4903', + 'Rule frontmatter globs must be a nonempty string or an array of nonempty strings.', + source, + )); + } + } + + const alwaysApply = declared.alwaysApply; + if (alwaysApply !== undefined) { + if (typeof alwaysApply === 'boolean') frontmatter.alwaysApply = alwaysApply; + else diagnostics.push(diagnostic('AB4903', 'Rule frontmatter alwaysApply must be a boolean.', source)); + } + + const targets = declared.targets; + let authoredTargets: readonly string[] | undefined; + if (targets !== undefined) { + if (Array.isArray(targets) && targets.every(nonemptyString)) { + authoredTargets = [...targets]; + } else { + diagnostics.push(diagnostic( + 'AB4903', + 'Rule frontmatter targets must be an array of nonempty target names.', + source, + )); + } + } + + return { + ...(authoredTargets === undefined ? {} : { authoredTargets }), + diagnostics, + frontmatter, + }; +}; + +const emittedMarkdown = ( + markdown: string, + body: string, + declared: Readonly>, + frontmatter: Readonly>, +): string => { + if (!Object.hasOwn(declared, 'targets')) return markdown; + if (Object.keys(frontmatter).length === 0) return body; + return `---\n${stringifyYaml(frontmatter)}---\n${body.startsWith('\n') ? body : `\n${body}`}`; +}; + +export const parseRule = async (source: string): Promise => { + let markdown: string; + try { + markdown = await readFile(source, 'utf8'); + } catch (error: unknown) { + return { + body: '', + diagnostics: [diagnostic( + 'AB4900', + `Unable to read rule file: ${error instanceof Error ? error.message : String(error)}`, + source, + )], + emittedMarkdown: '', + frontmatter: {}, + markdown: '', + source, + }; + } + + const parsed = parseMarkdownFrontmatter(markdown); + if (parsed.status === 'missing-frontmatter') { + return { + body: parsed.body, + diagnostics: [], + emittedMarkdown: markdown, + frontmatter: {}, + markdown, + source, + }; + } + if (parsed.status === 'malformed-frontmatter') { + return { + body: parsed.body, + diagnostics: [diagnostic( + 'AB4901', + `Rule YAML frontmatter is invalid: ${parsed.message}`, + source, + )], + emittedMarkdown: markdown, + frontmatter: {}, + markdown, + source, + }; + } + + const validated = validateFrontmatter(parsed.frontmatter, source); + return { + ...(validated.authoredTargets === undefined ? {} : { authoredTargets: validated.authoredTargets }), + body: parsed.body, + diagnostics: validated.diagnostics, + emittedMarkdown: emittedMarkdown(markdown, parsed.body, parsed.frontmatter, validated.frontmatter), + frontmatter: validated.frontmatter, + markdown, + source, + }; +}; diff --git a/packages/agent-bundle/src/config/skill-references.ts b/packages/agent-bundle/src/config/skill-references.ts index d6a2b3df9..2163c0e6d 100644 --- a/packages/agent-bundle/src/config/skill-references.ts +++ b/packages/agent-bundle/src/config/skill-references.ts @@ -2,14 +2,15 @@ import { posix } from 'node:path'; import { parse as parseYaml } from 'yaml'; -export type ParsedSkillMarkdown = +export type ParsedMarkdownFrontmatter = | { readonly body: string; readonly status: 'missing-frontmatter' } | { readonly body: string; readonly message: string; readonly status: 'malformed-frontmatter' } | { readonly body: string; readonly frontmatter: Record; readonly status: 'valid' }; const frontmatterPattern = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; -export const parseSkillMarkdown = (markdown: string): ParsedSkillMarkdown => { +/** Splits optional YAML frontmatter without imposing a component-specific schema. */ +export const parseMarkdownFrontmatter = (markdown: string): ParsedMarkdownFrontmatter => { const match = frontmatterPattern.exec(markdown); if (match === null) { return { body: markdown, status: 'missing-frontmatter' }; @@ -31,6 +32,10 @@ export const parseSkillMarkdown = (markdown: string): ParsedSkillMarkdown => { } }; +export type ParsedSkillMarkdown = ParsedMarkdownFrontmatter; + +export const parseSkillMarkdown = parseMarkdownFrontmatter; + const withoutMarkdownCode = (body: string): string => { let fence: { character: string; length: number } | undefined; diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index bbae8e267..4d6e02fce 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -877,6 +877,76 @@ const validateSkill = (skill: SkillDocument): Diagnostic[] => { return diagnostics; }; +const validateRules = ( + loaded: LoadedConfig, + discovered: DiscoveredProject, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const selectedTargets = selectedTargetNamesFor(loaded, registry); + const names = new Map(); + + for (const rule of discovered.rules ?? []) { + diagnostics.push(...rule.diagnostics); + const name = basename(rule.source, extname(rule.source)); + const firstSource = names.get(name); + if (firstSource === undefined) { + names.set(name, rule.source); + } else { + diagnostics.push(sourceDiagnostic( + 'AB4906', + `Rule name ${JSON.stringify(name)} duplicates ${firstSource}.`, + rule.source, + )); + } + + for (const target of rule.authoredTargets ?? []) { + if (!registry.has(target) || !selectedTargets.includes(target)) { + diagnostics.push({ + code: 'AB4904', + message: `Rule ${JSON.stringify(name)} selects target ${JSON.stringify(target)} outside the selected target names.`, + severity: 'error', + sourcePath: rule.source, + target, + }); + continue; + } + const capability = registry.capabilityState?.(target, 'rules'); + if (capability === undefined) { + if (registry.supports(target, 'rules')) continue; + diagnostics.push({ + code: 'AB4905', + message: `Rule ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose rules capability is unavailable: the target declares no supported rules surface.`, + severity: 'error', + sourcePath: rule.source, + target, + }); + continue; + } + switch (capability.state) { + case 'supported': + break; + case 'degraded': + case 'prohibited': + case 'unavailable': + diagnostics.push({ + code: 'AB4905', + message: `Rule ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose rules capability is ${capability.state}: ${capability.reason}`, + severity: 'error', + sourcePath: rule.source, + target, + }); + break; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } + } + } + return diagnostics; +}; + const isSafePackageOutputName = (name: string): boolean => /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(name); @@ -1616,6 +1686,7 @@ export const validateSource = ( diagnostics.push(...validateMcp(loaded, registry, payloads)); diagnostics.push(...validatePayload(loaded, registry, options?.payloadFreshness !== false)); diagnostics.push(...validateRuntime(loaded)); + diagnostics.push(...validateRules(loaded, discovered, registry)); diagnostics.push(...validateScripts(loaded, registry)); diagnostics.push(...validateTools(loaded)); diagnostics.push(...packageConventionShadowNudges(loaded)); @@ -1662,6 +1733,7 @@ export const validateModel = ( ...Object.values(model.extensions), ...model.targets, ...model.skills, + ...(model.rules ?? []), ...model.hooks, ...model.mcpServers, ...(model.mcpApps ?? []), @@ -1889,6 +1961,10 @@ export const validateModel = ( if (!asset.targets.includes(target.name)) continue; recordOutput(posix.join(target.name, 'assets', asset.relativePath), asset.source, target.name); } + for (const rule of model.rules ?? []) { + if (!rule.targets.includes(target.name)) continue; + recordOutput(posix.join(target.name, 'rules', `${rule.name}.mdc`), rule.source, target.name); + } for (const payload of model.payloads ?? []) { if (!payload.targets.includes(target.name)) continue; for (const file of payload.files) { diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index 0936d916f..c296a7ce0 100644 --- a/packages/agent-bundle/src/core/project-context.ts +++ b/packages/agent-bundle/src/core/project-context.ts @@ -254,6 +254,7 @@ const canonicalHostDocuments = ( const modelPathReferences = (model: NormalizedPlugin): readonly string[] => [ model.metadata.provenance.sourcePath, ...(model.assets ?? []).flatMap((asset) => [asset.provenance.sourcePath, asset.source]), + ...(model.rules ?? []).flatMap((rule) => [rule.provenance.sourcePath, rule.source]), ...Object.values(model.extensions).map((extension) => extension.provenance.sourcePath), ...model.targets.map((target) => target.provenance.sourcePath), // A prebuilt hook's source is its payload file, which may not exist yet @@ -417,6 +418,15 @@ export const canonicalizeNormalizedModel = ( }), }, }), + ...(detached.rules === undefined + ? {} + : { + rules: detached.rules.map((rule) => ({ + ...rule, + provenance: canonicalProvenance(root, rule.provenance), + source: canonicalCompilerPath(root, rule.source, 'Rule source path'), + })), + }), scripts: detached.scripts.map((script) => ({ ...script, provenance: canonicalProvenance(root, script.provenance), diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index d398d7b09..bbdea236f 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -321,6 +321,21 @@ export interface NormalizedSkill { readonly targets: readonly string[]; } +/** One conventional Cursor `.mdc` rule with peeled target selection. */ +export interface NormalizedRule { + readonly body: string; + /** Host-emitted document with authoring-only frontmatter keys stripped. */ + readonly emittedMarkdown: string; + readonly frontmatter: Readonly>; + readonly id: string; + /** Exact authored bytes decoded as UTF-8; retained as an identity input. */ + readonly markdown: string; + readonly name: string; + readonly provenance: SourceProvenance; + readonly source: string; + readonly targets: readonly string[]; +} + export interface NormalizedMcpServer { readonly args?: readonly string[]; /** Filesystem routes compiled into this framework-generated server entry. */ @@ -502,6 +517,11 @@ export interface NormalizedPlugin { * models predating prebuilt payloads stay valid. */ readonly payloads?: readonly NormalizedPayload[]; + /** + * Conventional `rules/*.mdc` documents. Present only when rules are + * discovered; optional so hand-constructed models predating rules remain valid. + */ + readonly rules?: readonly NormalizedRule[]; /** The generated-executable runtime floor selected during normalization. */ readonly runtime: NormalizedRuntime; readonly scripts: readonly NormalizedScript[]; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 7dfd51510..2919de798 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -23,13 +23,37 @@ const state = (value: CapabilityState): CapabilityState => Object.freeze(value); it('keeps the plugin Boolean capability view as the Claude and Codex intersection', () => { const registry = createDefaultRegistry(); - for (const capability of ['marketplace', 'hooks', 'lsp', 'mcp', 'skills']) { + for (const capability of ['marketplace', 'hooks', 'lsp', 'mcp', 'rules', 'skills']) { expect(registry.supports('plugin', capability)).toBe( registry.supports('claude', capability) && registry.supports('codex', capability), ); } }); +it('records an honest four-state rules row on every adapter', () => { + const registry = createDefaultRegistry(); + expect(registry.get('cursor').capabilities.rules).toMatchObject({ + evidence: { observedVersion: '2026-08-28', target: 'cursor' }, + state: 'supported', + }); + expect(registry.get('claude').capabilities.rules).toEqual({ + reason: 'The pinned Claude Code plugin contract (2.1.250) defines no rules component; project guidance ships through CLAUDE.md memory, not a rules directory.', + state: 'unavailable', + }); + expect(registry.get('codex').capabilities.rules).toEqual({ + reason: 'The pinned Codex plugin contract (0.147.0) defines no rules component; Codex guidance remains outside the plugin component surface.', + state: 'unavailable', + }); + expect(registry.get('portable').capabilities.rules).toEqual({ + reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no rules surface.', + state: 'unavailable', + }); + expect(registry.get('plugin').capabilities.rules).toEqual(intersectCapabilityStates( + registry.get('claude').capabilities.rules!, + registry.get('codex').capabilities.rules!, + )); +}); + it('reports Claude LSP support and honest unavailable composite coverage', () => { const registry = createDefaultRegistry(); @@ -181,7 +205,7 @@ it('surfaces built-in adapter metadata as immutable capability evidence', () => if (cursor.capabilities.mcp?.state !== 'supported') throw new Error('Expected Cursor MCP support evidence.'); expect(cursor.capabilities.mcp.evidence).toEqual({ capabilityRevision: '2026-08-28', - capabilitySha256: '20fc70ad5ba67d984826c3ac917fca66f28e61a8c74edb65dace53c29cc67279', + capabilitySha256: '20e93666770d97c0f5c1338de395f326c869684843b3b06bbd7ba3c45a0abc3e', observedVersion: '2026-08-28', target: 'cursor', }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index ed81f8548..462d97ff7 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -135,7 +135,7 @@ it('records exact immutable metadata for every built-in target', () => { expect(registryMetadata(registry, 'cursor')).toEqual({ adapterRevision: '1.3.0', capabilityRevision: '2026-08-28', - capabilitySha256: '20fc70ad5ba67d984826c3ac917fca66f28e61a8c74edb65dace53c29cc67279', + capabilitySha256: '20e93666770d97c0f5c1338de395f326c869684843b3b06bbd7ba3c45a0abc3e', observedVersion: '2026-08-28', schemas: [ { diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index 0bd369f65..aaec241cb 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -545,25 +545,47 @@ it('returns an invalid inspection for selected targets outside the normalized pr it('reports skipped target/component pairs with intersection-rule reasons', async () => { const root = await createProject(); try { - await writeFile(join(root, 'src', 'report.ts'), 'export const report = true;\n'); - await writeFile(join(root, 'agent-bundle.config.ts'), [ - 'export default {', - " hooks: { sessionStart: { handler: './src/hook.ts' } },", - " plugin: { name: 'api-fixture', version: '1.0.0' },", - " scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },", - " targets: ['portable', 'codex'],", - '};', - '', - ].join('\n')); + await mkdir(join(root, 'rules')); + await Promise.all([ + writeFile(join(root, 'src', 'report.ts'), 'export const report = true;\n'), + writeFile(join(root, 'rules', 'shared.mdc'), '---\ndescription: Shared rule\n---\nShared guidance.\n'), + writeFile( + join(root, 'rules', 'cursor-only.mdc'), + '---\ndescription: Cursor-only rule\ntargets:\n - cursor\n---\nCursor guidance.\n', + ), + writeFile(join(root, 'agent-bundle.config.ts'), [ + 'export default {', + " hooks: { sessionStart: { handler: './src/hook.ts' } },", + " plugin: { name: 'api-fixture', version: '1.0.0' },", + " scripts: { report: { entry: './src/report.ts', targets: ['codex'] } },", + " targets: ['portable', 'codex', 'claude', 'cursor'],", + '};', + '', + ].join('\n')), + ]); const result = await readyInspection({ root }); const planFor = (target: string) => result.plans.find((plan) => plan.target === target); expect(planFor('portable')?.skipped).toEqual([ expect.objectContaining({ kind: 'hook', name: 'sessionStart', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), + expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), + ]); + expect(planFor('codex')?.skipped).toEqual([ + expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), + ]); + expect(planFor('claude')?.skipped).toEqual([ + expect.objectContaining({ kind: 'rule', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'rule', name: 'shared', reason: 'unsupported-capability' }), + expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), + ]); + expect(planFor('cursor')?.skipped).toEqual([ expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), ]); - expect(planFor('codex')?.skipped).toEqual([]); + expect(planFor('cursor')?.skipped.some((component) => component.kind === 'rule')).toBe(false); expect(Object.isFrozen(planFor('portable')?.skipped)).toBe(true); } finally { await rm(join(root, '..'), { force: true, recursive: true }); @@ -777,6 +799,60 @@ it('returns an output-independent project context without absolute project paths } }, 30_000); +it('keeps rule model digests root-independent and sensitive to rule content', async () => { + const [leftRoot, rightRoot] = await Promise.all([createProject(), createProject()]); + const config = [ + 'export default {', + " plugin: { name: 'rule-digest-fixture', version: '1.0.0' },", + " targets: ['cursor', 'claude'],", + '};', + '', + ].join('\n'); + const targetedRule = [ + '---', + 'description: Cursor-only guidance', + 'targets:', + ' - cursor', + '---', + 'Targeted body.', + '', + ].join('\n'); + const sharedRule = '---\ndescription: Shared guidance\n---\nShared body.\n'; + try { + await Promise.all([ + mkdir(join(leftRoot, 'rules')), + mkdir(join(rightRoot, 'rules')), + ]); + await Promise.all([ + writeFile(join(leftRoot, 'agent-bundle.config.ts'), config), + writeFile(join(rightRoot, 'agent-bundle.config.ts'), config), + writeFile(join(leftRoot, 'rules', 'cursor-only.mdc'), targetedRule), + writeFile(join(rightRoot, 'rules', 'cursor-only.mdc'), targetedRule), + writeFile(join(leftRoot, 'rules', 'shared.mdc'), sharedRule), + writeFile(join(rightRoot, 'rules', 'shared.mdc'), sharedRule), + ]); + + const [left, right] = await Promise.all([ + readyInspection({ root: leftRoot }), + readyInspection({ root: rightRoot }), + ]); + expect(left.projectContext.modelDigest).toBe(right.projectContext.modelDigest); + expect(left.projectContext.sourceInputs.map((input) => input.path)).toEqual(expect.arrayContaining([ + 'rules/cursor-only.mdc', + 'rules/shared.mdc', + ])); + + await writeFile(join(rightRoot, 'rules', 'shared.mdc'), sharedRule.replace('Shared body.', 'Changed body.')); + const changed = await readyInspection({ root: rightRoot }); + expect(changed.projectContext.modelDigest).not.toBe(left.projectContext.modelDigest); + } finally { + await Promise.all([ + rm(join(leftRoot, '..'), { force: true, recursive: true }), + rm(join(rightRoot, '..'), { force: true, recursive: true }), + ]); + } +}); + it('rejects an output beneath an escaping symlink before loading source or writing outside the project', async () => { const root = await createProject(); const external = join(root, '..', 'external-output'); diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index e2972a4df..b3518d73f 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -187,10 +187,11 @@ const customRegistry = (validate = validateCustomDocument): TargetRegistry => ne }, artifactLayout: { assets: 'assets', + rules: { allowedSuffixes: ['.mdc'], directory: 'rules' }, scripts: { allowedSuffixes: ['.json', '.mjs', '.sh'], directory: 'scripts' }, skills: 'skills', }, - capabilities: supportedCapabilities('skills'), + capabilities: supportedCapabilities('rules', 'skills'), metadata: customMetadata, name: customTarget, plan: () => ({ diagnostics: [], entries: [] }), @@ -224,6 +225,35 @@ const customSkillFiles = (body: string, resources: readonly ArtifactFixtureFile[ ...resources, ]; +it('admits only direct .mdc files in a declared rules layout', async () => { + const registry = customRegistry(); + const target = targetFromRegistry(registry, customTarget); + const validRoot = await writeArtifact([ + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '# Rule\n', kind: 'generated', path: 'custom/rules/review.mdc' }, + ], true, [target]); + const invalidRoot = await writeArtifact([ + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '# Rule\n', kind: 'generated', path: 'custom/rules/review.md' }, + ], true, [target]); + + try { + expect(await validateArtifact({ artifactRoot: validRoot, registry })).toEqual([]); + expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( + expect.objectContaining({ + code: 'AB6014', + generatedPath: 'custom/rules/review.md', + target: customTarget, + }), + ); + } finally { + await Promise.all([ + rm(validRoot, { force: true, recursive: true }), + rm(invalidRoot, { force: true, recursive: true }), + ]); + } +}); + it('reports every legacy SSE MCP issue in lexical order with escaped JSON Pointer paths', () => { let schemaCalls = 0; const validate = validateModernMcpDocument(() => { diff --git a/packages/agent-bundle/tests/build.test.ts b/packages/agent-bundle/tests/build.test.ts index 874e1fd86..414e8b889 100644 --- a/packages/agent-bundle/tests/build.test.ts +++ b/packages/agent-bundle/tests/build.test.ts @@ -339,6 +339,7 @@ it('low-level build writes and returns the exact canonical manifest for a config const files = (await treeDigest(project.outputRoot)).filter( (entry) => entry.path !== 'agent-bundle.manifest.json', ); + expect(files.some((entry) => entry.path.includes('/rules/'))).toBe(false); expect(result.manifest).toEqual(manifest); expect(manifestBytes).toBe(serializeArtifactManifest(result.manifest)); expect(manifest).toMatchObject({ diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index ae01ca710..6d7ba6284 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -81,6 +81,7 @@ it('registers cursor as a first-class target with pinned schema validation', () expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(registry.supports('cursor', 'mcp')).toBe(true); + expect(registry.supports('cursor', 'rules')).toBe(true); expect(registry.supports('cursor', 'skills')).toBe(true); expect(registry.supports('cursor', 'hooks')).toBe(true); expect(registry.hookContract('cursor')?.commandRoot).toBe('${CURSOR_PLUGIN_ROOT}'); @@ -89,6 +90,10 @@ it('registers cursor as a first-class target with pinned schema validation', () { path: 'hooks/hooks.json', required: false, schema: 'hooks' }, { path: 'mcp.json', required: false, schema: 'mcp' }, ]); + expect(registry.artifactLayout('cursor').rules).toEqual({ + allowedSuffixes: ['.mdc'], + directory: 'rules', + }); }); it('holds the 64-character plugin-name bound in both Cursor-producing planners', () => { @@ -197,6 +202,54 @@ it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit expect(skillCopies).toEqual(['skills/review/SKILL.md', 'skills/review/references/guide.md']); }); +it('emits selected rules byte-faithfully and omits the entire surface when rule-free', () => { + const model = plugin(); + const markdown = '---\r\ndescription: Review TypeScript\r\nglobs: "**/*.ts"\r\n---\r\nCheck types.'; + const withRule: NormalizedPlugin = { + ...model, + rules: [{ + body: 'Check types.', + emittedMarkdown: markdown, + frontmatter: { description: 'Review TypeScript', globs: '**/*.ts' }, + id: 'rule:typescript', + markdown, + name: 'typescript', + provenance: { kind: 'conventional', sourcePath: '/workspace/rules/typescript.mdc' }, + source: '/workspace/rules/typescript.mdc', + targets: ['cursor'], + }, { + body: 'Keep changes focused.', + emittedMarkdown: '---\ndescription: Focus changes\nalwaysApply: true\n---\n\nKeep changes focused.', + frontmatter: { alwaysApply: true, description: 'Focus changes' }, + id: 'rule:focused', + markdown: '---\ndescription: Focus changes\nalwaysApply: true\ntargets:\n - cursor\n---\nKeep changes focused.', + name: 'focused', + provenance: { kind: 'conventional', sourcePath: '/workspace/rules/focused.mdc' }, + source: '/workspace/rules/focused.mdc', + targets: ['cursor'], + }], + }; + + const plan = cursorAdapter.plan(withRule); + const documents = writeContents(withRule); + expect(plan.diagnostics).toEqual([]); + expect(documents['rules/typescript.mdc']).toBe(markdown); + expect(documents['rules/focused.mdc']).toBe( + '---\ndescription: Focus changes\nalwaysApply: true\n---\n\nKeep changes focused.', + ); + expect(documents['rules/focused.mdc']).not.toContain('targets:'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ + rules: './rules/', + }); + expect(plan.entries.find((entry) => entry.relativePath === 'rules/typescript.mdc')?.sourceInputs).toEqual([ + '/workspace/rules/typescript.mdc', + ]); + + const ruleFree = cursorAdapter.plan(model); + expect(ruleFree.entries.some((entry) => entry.relativePath.startsWith('rules/'))).toBe(false); + expect(JSON.parse(writeContents(model)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('rules'); +}); + it('rejects portable Agent Plugin tokens instead of emitting a hybrid Cursor artifact', () => { const model = plugin(); const candidate: NormalizedPlugin = { diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 9686dec75..5ec5f0535 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -213,6 +213,42 @@ it('emits each shared surface exactly once with no duplicate artifact paths', () expect(new Set(hookEntries.map((entry) => entry.target))).toEqual(new Set(['plugin'])); }); +it('emits Cursor-only rules once at the shared root and documents the honest host boundary', () => { + const markdown = '---\ndescription: Keep changes focused\n---\nStay focused.'; + const model: NormalizedPlugin = { + ...bundleModel, + rules: [{ + body: 'Stay focused.', + emittedMarkdown: markdown, + frontmatter: { description: 'Keep changes focused' }, + id: 'rule:focused', + markdown, + name: 'focused', + provenance: { kind: 'conventional', sourcePath: '/workspace/rules/focused.mdc' }, + source: '/workspace/rules/focused.mdc', + targets: ['plugin'], + }], + }; + const plan = planBundle(model); + const documents = writeContents(model); + const paths = plan.entries.map((entry) => entry.relativePath); + + expect(plan.diagnostics).toEqual([]); + expect(paths.filter((path) => path === 'rules/focused.mdc')).toHaveLength(1); + expect(documents['rules/focused.mdc']).toBe(markdown); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ rules: './rules/' }); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('rules'); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('rules'); + expect(documents['AGENTS.md']).toContain( + '- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.', + ); + + const ruleFree = planBundle(bundleModel); + expect(ruleFree.entries.some((entry) => entry.relativePath.startsWith('rules/'))).toBe(false); + expect(writeContents(bundleModel)['AGENTS.md']).not.toContain('`rules/`'); + expect(JSON.parse(writeContents(bundleModel)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('rules'); +}); + it('bakes runtime host detection into the universal wrapper source', () => { const plan = planBundle(bundleModel); const wrapper = (plan.hookEntries ?? []).find((entry) => entry.relativePath === 'hooks/session-start.mjs'); diff --git a/packages/agent-bundle/tests/rule-config.test.ts b/packages/agent-bundle/tests/rule-config.test.ts new file mode 100644 index 000000000..b9c77f26a --- /dev/null +++ b/packages/agent-bundle/tests/rule-config.test.ts @@ -0,0 +1,259 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { + discoverProject, + normalizeProject, + parseRule, + validateSource, + type DiscoveredProject, + type LoadedConfig, + type RuleDocument, +} from '../src/config/index.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; + +const loadedProject = ( + root: string, + targets: readonly string[], +): LoadedConfig => ({ + config: { + plugin: { name: 'rule-fixture', version: '1.0.0' }, + targets: [...targets], + }, + configPath: join(root, 'agent-bundle.config.ts'), + context: { + command: 'build', + mode: 'production', + projectRoot: root, + selectedTargets: [], + }, +}); + +const withProject = async ( + run: (root: string) => Promise, +): Promise => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-rules-')); + try { + await writeFile( + join(root, 'agent-bundle.config.ts'), + "export default { plugin: { name: 'rule-fixture', version: '1.0.0' } };\n", + ); + await run(root); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +it('accepts body-only rules and retains exact authored bytes', async () => { + await withProject(async (root) => { + const source = join(root, 'rules', 'review.mdc'); + const markdown = '# Review\n\nCheck the staged diff.'; + await mkdir(join(root, 'rules')); + await writeFile(source, markdown); + + const rule = await parseRule(source); + + expect(rule).toMatchObject({ + body: markdown, + diagnostics: [], + emittedMarkdown: markdown, + frontmatter: {}, + markdown, + source, + }); + expect(rule).not.toHaveProperty('authoredTargets'); + }); +}); + +it('peels targets and rejects unknown frontmatter fields and malformed field shapes', async () => { + await withProject(async (root) => { + await mkdir(join(root, 'rules')); + const validSource = join(root, 'rules', 'cursor-only.mdc'); + const validMarkdown = [ + '---', + 'description: Cursor review guidance', + 'globs:', + ' - "**/*.ts"', + 'alwaysApply: false', + 'targets:', + ' - cursor', + '---', + '# Review', + '', + ].join('\n'); + await writeFile( + validSource, + validMarkdown, + ); + const valid = await parseRule(validSource); + expect(valid.diagnostics).toEqual([]); + expect(valid.frontmatter).toEqual({ + alwaysApply: false, + description: 'Cursor review guidance', + globs: ['**/*.ts'], + }); + expect(valid.authoredTargets).toEqual(['cursor']); + expect(valid.markdown).toBe(validMarkdown); + expect(valid.emittedMarkdown).toBe([ + '---', + 'description: Cursor review guidance', + 'globs:', + ' - "**/*.ts"', + 'alwaysApply: false', + '---', + '', + '# Review', + '', + ].join('\n')); + expect(valid.emittedMarkdown).not.toContain('targets:'); + + const invalidSource = join(root, 'rules', 'invalid.mdc'); + await writeFile( + invalidSource, + [ + '---', + 'description: 42', + 'globs: [""]', + 'alwaysApply: yes', + 'targets: cursor', + 'extra: hidden', + '---', + '# Invalid', + '', + ].join('\n'), + ); + const invalid = await parseRule(invalidSource); + expect(invalid.diagnostics.map(({ code }) => code)).toEqual([ + 'AB4902', + 'AB4903', + 'AB4903', + 'AB4903', + 'AB4903', + ]); + expect(invalid.frontmatter).toEqual({}); + expect(invalid).not.toHaveProperty('authoredTargets'); + }); +}); + +it('preserves target-free frontmatter bytes and emits target-only rules as body-only', async () => { + await withProject(async (root) => { + await mkdir(join(root, 'rules')); + const targetFreeSource = join(root, 'rules', 'target-free.mdc'); + const targetFreeMarkdown = '---\r\ndescription: Keep CRLF\r\n---\r\nBody without trailing newline'; + const targetOnlySource = join(root, 'rules', 'target-only.mdc'); + await Promise.all([ + writeFile(targetFreeSource, targetFreeMarkdown), + writeFile(targetOnlySource, '---\ntargets:\n - cursor\n---\nTarget-only body'), + ]); + + const [targetFree, targetOnly] = await Promise.all([ + parseRule(targetFreeSource), + parseRule(targetOnlySource), + ]); + + expect(targetFree.emittedMarkdown).toBe(targetFreeMarkdown); + expect(targetOnly.frontmatter).toEqual({}); + expect(targetOnly.authoredTargets).toEqual(['cursor']); + expect(targetOnly.emittedMarkdown).toBe('Target-only body'); + }); +}); + +it('reports malformed YAML frontmatter with a fresh rule diagnostic', async () => { + await withProject(async (root) => { + const source = join(root, 'rules', 'malformed.mdc'); + await mkdir(join(root, 'rules')); + await writeFile(source, '---\nglobs: [unterminated\n---\n# Broken\n'); + + expect((await parseRule(source)).diagnostics).toEqual([ + expect.objectContaining({ code: 'AB4901', severity: 'error', sourcePath: source }), + ]); + }); +}); + +it('discovers flat non-ignored rules deterministically and omits the collection when empty', async () => { + await withProject(async (root) => { + await mkdir(join(root, 'rules')); + await Promise.all([ + writeFile(join(root, '.gitignore'), 'rules/ignored.mdc\n'), + writeFile(join(root, 'rules', 'zeta.mdc'), '# Zeta\n'), + writeFile(join(root, 'rules', 'alpha.mdc'), '# Alpha\n'), + writeFile(join(root, 'rules', 'ignored.mdc'), '# Ignored\n'), + ]); + const config: AgentBundleConfig = { + plugin: { name: 'rule-fixture', version: '1.0.0' }, + }; + + const discovered = await discoverProject(root, config); + expect(discovered.rules?.map((rule) => rule.source)).toEqual([ + join(root, 'rules', 'alpha.mdc'), + join(root, 'rules', 'zeta.mdc'), + ]); + + await rm(join(root, 'rules'), { recursive: true }); + expect(await discoverProject(root, config)).not.toHaveProperty('rules'); + }); +}); + +it('normalizes peeled rule targets and reports unknown, unavailable, and duplicate rules', async () => { + await withProject(async (root) => { + const rule = ( + source: string, + authoredTargets?: readonly string[], + ): RuleDocument => ({ + ...(authoredTargets === undefined ? {} : { authoredTargets }), + body: '# Rule\n', + diagnostics: [], + emittedMarkdown: '---\ndescription: Rule guidance\n---\n# Rule\n', + frontmatter: { description: 'Rule guidance' }, + markdown: '---\ndescription: Rule guidance\n---\n# Rule\n', + source, + }); + const registry = createDefaultRegistry(); + const cursorLoaded = loadedProject(root, ['cursor']); + const cursorRule = rule(join(root, 'rules', 'review.mdc'), ['cursor']); + const cursorDiscovered: DiscoveredProject = { rules: [cursorRule], skills: [] }; + const model = await normalizeProject(cursorLoaded, cursorDiscovered, registry); + + expect(model.rules).toEqual([{ + body: '# Rule\n', + emittedMarkdown: '---\ndescription: Rule guidance\n---\n# Rule\n', + frontmatter: { description: 'Rule guidance' }, + id: 'rule:review', + markdown: '---\ndescription: Rule guidance\n---\n# Rule\n', + name: 'review', + provenance: { kind: 'conventional', sourcePath: cursorRule.source }, + source: cursorRule.source, + targets: ['cursor'], + }]); + + const unknown = rule(join(root, 'rules', 'unknown.mdc'), ['claude']); + expect(validateSource(cursorLoaded, { rules: [unknown], skills: [] }, registry)).toEqual([ + expect.objectContaining({ code: 'AB4904', sourcePath: unknown.source }), + ]); + + const claudeLoaded = loadedProject(root, ['claude']); + const unavailable = rule(join(root, 'rules', 'unavailable.mdc'), ['claude']); + expect(validateSource(claudeLoaded, { rules: [unavailable], skills: [] }, registry)).toEqual([ + expect.objectContaining({ + code: 'AB4905', + message: expect.stringContaining('unavailable'), + sourcePath: unavailable.source, + }), + ]); + + const duplicateFirst = rule(join(root, 'first', 'duplicate.mdc')); + const duplicateSecond = rule(join(root, 'second', 'duplicate.mdc')); + expect(validateSource( + cursorLoaded, + { rules: [duplicateFirst, duplicateSecond], skills: [] }, + registry, + )).toContainEqual(expect.objectContaining({ + code: 'AB4906', + sourcePath: duplicateSecond.source, + })); + }); +});