Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cursor-rules-surface.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"observedCliVersion": "2026-08-28",
"plugin": {
"manifest": ".cursor-plugin/plugin.json",
"rules": true,
"skills": true,
"localInstall": {
"method": "copy",
Expand All @@ -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."
]
}
Expand Down
4 changes: 4 additions & 0 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/adapters/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<string, unknown>;
}
Expand All @@ -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,
Expand All @@ -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),
});
Expand Down Expand Up @@ -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)));
Expand All @@ -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,
Expand All @@ -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),
Expand All @@ -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,
Expand Down
23 changes: 22 additions & 1 deletion packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
type TargetHookContract,
} from './hook-contract.ts';
import {
ruleWriteEntries,
sortedEntries,
sourceInputs,
standardArtifactLayout,
Expand Down Expand Up @@ -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,
});
Expand All @@ -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 => {
Expand Down Expand Up @@ -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 (`<name>.cursor.mjs`).',
'- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.',
'- `scripts/`, `mcp/`, `mcp-apps/`, `assets/` — compiled shared surfaces.',
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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 }),
});
Expand All @@ -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({
Expand All @@ -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',
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/adapters/portable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/agent-bundle/src/adapters/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -218,6 +219,7 @@ const snapshotArtifactLayout = (
...(mcpApps === undefined ? {} : { mcpApps }),
...(mcpEntries === undefined ? {} : { mcpEntries }),
...(rootDocuments === undefined ? {} : { rootDocuments }),
...(rules === undefined ? {} : { rules }),
...(scripts === undefined ? {} : { scripts }),
...(skills === undefined ? {} : { skills }),
});
Expand Down
17 changes: 17 additions & 0 deletions packages/agent-bundle/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand All @@ -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,
Expand Down Expand Up @@ -223,6 +238,7 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput):
...hookSourceInputs,
...nativeHookSourceInputs,
...skillSourceInputs,
...(input.additionalPluginSourceInputs ?? []),
),
}];
if (mcp !== undefined && mcpValid) {
Expand Down Expand Up @@ -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;
}
Expand Down
3 changes: 2 additions & 1 deletion packages/agent-bundle/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 })),
];
Expand Down
1 change: 1 addition & 0 deletions packages/agent-bundle/src/build/validate-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-bundle/src/config/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)),
Expand Down
3 changes: 3 additions & 0 deletions packages/agent-bundle/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -47,6 +49,7 @@ export type {
NormalizedPayloadFile,
NormalizedPlugin,
NormalizedRuntime,
NormalizedRule,
NormalizedScript,
NormalizedSkill,
NormalizedSkillResource,
Expand Down
Loading
Loading