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/host-command-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"agent-bundle": minor
---

Add conventional `commands/*.md` authoring with validated Cursor and Claude
command emission and honest capability states for unsupported hosts.
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
},
"observedCliVersion": "2.1.250",
"plugin": {
"commands": true,
"devtools": {
"details": true,
"listJson": true,
Expand Down Expand Up @@ -87,6 +88,7 @@
"Placeholder substitution for LSP servers is limited to command, args, env, and workspaceFolder.",
"Codex and Cursor publish no plugin LSP surface at their pinned revisions, so the unified bundle's .lsp.json reaches Claude Code only.",
"Plugin developer tools reference: `claude plugin validate <dir>` checks plugin.json, hooks/hooks.json, and default-directory Skill, agent, and command frontmatter; manifest-less component directories require 2.1.233 or later.",
"2026-09-01: https://code.claude.com/docs/en/plugins-reference documents plugin commands/ as Markdown files with optional YAML frontmatter fields description, argument-hint, allowed-tools, model, and disable-model-invocation; the filename stem is the plugin-namespaced command name.",
"`claude plugin validate <dir> --strict` promotes tolerated warnings such as unrecognized or near-miss fields and non-object experimental/metadata values to exit failure; the reference recommends strict mode in CI.",
"Development tools include `claude --plugin-dir <dir> plugin list --json` for registration proof, `claude plugin details <name>` for component inventory and host-owned token estimates, `claude plugin tag`, and `claude --debug` for loading diagnostics.",
"https://code.claude.com/docs/en/hooks documents SubagentStart when Agent spawns a subagent and SubagentStop when it finishes; both match agent_type, including anchored plugin-scoped identifiers such as ^my-plugin:reviewer$.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
},
"observedCliVersion": "2026-08-28",
"plugin": {
"commands": true,
"manifest": ".cursor-plugin/plugin.json",
"rules": true,
"skills": true,
Expand All @@ -75,6 +76,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-09-01: cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a schemas/plugin.schema.json defines the commands component pointer; https://cursor.com/docs documents agent chat commands as plain Markdown prompt files in commands/ named by filename.",
"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
46 changes: 42 additions & 4 deletions packages/agent-bundle/src/adapters/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,21 @@ import lspSchema from './schemas/claude/lsp.schema.json' with { type: 'json' };
import marketplaceSchema from './schemas/claude/marketplace.schema.json' with { type: 'json' };
import mcpSchema from './schemas/claude/mcp.schema.json' with { type: 'json' };
import pluginSchema from './schemas/claude/plugin.schema.json' with { type: 'json' };
import { stringify as stringifyYaml } from 'yaml';
import {
commandWriteEntries,
createAdapterValidator,
hasPathToken,
schemaDescriptorsFrom,
sortedEntries,
sourceInputs,
standardArtifactLayout,
standardPluginArtifactPlan,
validateJsonSchemaDocument,
validateModernMcpDocument,
withPluginRootEnvAnchor,
type TargetAdapter,
type TargetArtifactLayout,
type TargetArtifactPlan,
} from './types.ts';

Expand Down Expand Up @@ -133,9 +137,9 @@ const hookContract = Object.freeze({
wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'),
} satisfies TargetHookContract);
const metadata = Object.freeze({
adapterRevision: '1.3.0',
adapterRevision: '1.4.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: 'a9c8821ee5cbc6aef65816c5025170389fd490b6d1c4e4e893599aa6a36f2265',
capabilitySha256: '6b8a3b222b49c0ad22f32ecdf8157bd353ce5be05d56e40ae5cf4ad2b9eb917f',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -175,6 +179,20 @@ const mcpRuntime = createTargetMcpRuntime({

const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(claudeName, 'Claude');

const claudeCommandMarkdown = (
command: NonNullable<NormalizedPlugin['commands']>[number],
): string => {
const fields = [
['allowed-tools', command.frontmatter.allowedTools],
['argument-hint', command.frontmatter.argumentHint],
['description', command.frontmatter.description],
['disable-model-invocation', command.frontmatter.disableModelInvocation],
['model', command.frontmatter.model],
].filter((entry): entry is [string, unknown] => entry[1] !== undefined);
if (fields.length === 0) return command.body;
return `---\n${stringifyYaml(Object.fromEntries(fields))}---\n${command.body}`;
};

const expandClaudeToken = (value: string): string => value
.replaceAll(pathTokens.pluginRoot, '${CLAUDE_PLUGIN_ROOT}')
.replaceAll(pathTokens.pluginData, '${CLAUDE_PLUGIN_DATA}')
Expand Down Expand Up @@ -469,7 +487,7 @@ export const planClaudeArtifacts = (
diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors));
}

return standardPluginArtifactPlan({
const basePlan = standardPluginArtifactPlan({
diagnostics,
...(lsp.document === undefined ? {} : {
hostDocuments: [{
Expand All @@ -493,13 +511,33 @@ export const planClaudeArtifacts = (
pluginRelativePath: claudeArtifactPaths.plugin,
targetName,
});
return Object.freeze({
...basePlan,
entries: sortedEntries([
...basePlan.entries,
...commandWriteEntries(model, isSelected, claudeCommandMarkdown),
]),
});
};

const artifactLayout: TargetArtifactLayout = Object.freeze({
...standardArtifactLayout,
commands: Object.freeze({
allowedSuffixes: Object.freeze(['.md']),
directory: 'commands',
}),
});

export const claudeAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: standardArtifactLayout,
artifactLayout,
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence),
commands: capabilityStateFromSupport(
capabilityTable.plugin.commands,
evidence,
'The pinned Claude Code plugin contract does not support commands.',
),
marketplace: supportedCapability(evidence),
hooks: supportedCapability(evidence),
lsp: capabilityStateFromSupport(
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 @@ -411,6 +411,9 @@ export const codexAdapter: TargetAdapter = Object.freeze({
artifactLayout: standardArtifactLayout,
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence),
commands: unavailableCapability(
'The pinned Codex plugin contract (0.147.0) defines no commands component.',
),
marketplace: supportedCapability(evidence),
hooks: supportedCapability(evidence),
// The pinned Codex plugin contract documents no LSP surface at all, so
Expand Down
30 changes: 26 additions & 4 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import hooksSchema from './schemas/cursor/hooks.schema.json' with { type: 'json'
import mcpSchema from './schemas/cursor/mcp.schema.json' with { type: 'json' };
import pluginSchema from './schemas/cursor/plugin.schema.json' with { type: 'json' };
import {
commandWriteEntries,
createDraft7AdapterValidator,
ruleWriteEntries,
schemaDescriptorsFrom,
Expand Down Expand Up @@ -234,6 +235,7 @@ export const planCursorMcpServer = (
};

export interface CursorManifestPointers {
readonly commands?: string;
readonly hooks?: string;
readonly mcp?: string;
readonly rules?: string;
Expand All @@ -246,6 +248,7 @@ export const cursorManifest = (
model: NormalizedPlugin,
pointers: CursorManifestPointers,
): Record<string, unknown> => ({
...(pointers.commands === undefined ? {} : { commands: pointers.commands }),
description: model.metadata.description ?? model.metadata.name,
displayName: model.metadata.name,
...(pointers.hooks === undefined ? {} : { hooks: pointers.hooks }),
Expand All @@ -258,9 +261,9 @@ export const cursorManifest = (
});

const metadata = Object.freeze({
adapterRevision: '1.3.0',
adapterRevision: '1.4.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: '20e93666770d97c0f5c1338de395f326c869684843b3b06bbd7ba3c45a0abc3e',
capabilitySha256: '9884e0ffdb1c7fd1fe6d1071fa6944729d596e51f0ab87ad5ef2b0dd6fd8a981',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -319,6 +322,10 @@ const mcpPlanContext: CursorMcpServerPlanContext = Object.freeze({ codePrefix: c

const artifactLayout: TargetArtifactLayout = Object.freeze({
...standardArtifactLayout,
commands: Object.freeze({
allowedSuffixes: Object.freeze(['.md']),
directory: 'commands',
}),
rules: Object.freeze({
allowedSuffixes: Object.freeze(['.mdc']),
directory: 'rules',
Expand All @@ -327,6 +334,7 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({

export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => {
const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName);
const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets));
const selectedRules = (model.rules ?? []).filter((rule) => isSelected(rule.targets));
const diagnostics: Diagnostic[] = [];
if (!isValidCursorPluginName(model.metadata.name)) {
Expand All @@ -351,6 +359,7 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan

const variables = cursorVariables(mcp);
const plugin = cursorManifest(model, {
...(selectedCommands.length === 0 ? {} : { commands: './commands/' }),
...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}),
...(mcp !== undefined && mcpValid ? { mcp: `./${cursorArtifactPaths.mcp}` } : {}),
...(selectedRules.length === 0 ? {} : { rules: './rules/' }),
Expand All @@ -360,7 +369,10 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors));

const basePlan = standardPluginArtifactPlan({
additionalPluginSourceInputs: selectedRules.map((rule) => rule.source),
additionalPluginSourceInputs: [
...selectedCommands.map((command) => command.source),
...selectedRules.map((rule) => rule.source),
],
diagnostics,
hookDocument,
hookDocumentValid,
Expand All @@ -379,7 +391,12 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
});
return Object.freeze({
...basePlan,
entries: sortedEntries([...basePlan.entries, ...ruleWriteEntries(model, isSelected)]),
entries: sortedEntries([
...basePlan.entries,
...commandWriteEntries(model, isSelected, (command) =>
command.markdown === command.body ? command.markdown : command.body),
...ruleWriteEntries(model, isSelected),
]),
});
};

Expand All @@ -388,6 +405,11 @@ export const cursorAdapter: TargetAdapter = Object.freeze({
artifactLayout,
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.hooks.eventRoutes, evidence),
commands: capabilityStateFromSupport(
capabilityTable.plugin.commands,
evidence,
'The pinned Cursor Plugin contract does not support commands.',
),
hooks: supportedCapability(evidence),
marketplace: unavailableCapability('The pinned Cursor Plugin contract does not define a marketplace document.'),
mcp: capabilityStateFromSupport(
Expand Down
14 changes: 14 additions & 0 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ const mcpRuntime = createTargetMcpRuntime({

const artifactLayout: TargetArtifactLayout = Object.freeze({
assets: standardArtifactLayout.assets,
commands: Object.freeze({ allowedSuffixes: Object.freeze(['.md']), directory: 'commands' }),
hookWrappers: standardArtifactLayout.hookWrappers,
mcpApps: standardArtifactLayout.mcpApps,
mcpEntries: standardArtifactLayout.mcpEntries,
Expand All @@ -211,6 +212,8 @@ const artifactLayout: TargetArtifactLayout = Object.freeze({
const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(pluginName, 'Agent plugin bundle');

interface AgentsDocumentOptions {
/** True when the Claude half emitted conventional command prompts. */
readonly commands: boolean;
/** True when the Claude half of this bundle emitted `.lsp.json`. */
readonly lsp: boolean;
/** True when the Cursor half emitted conventional `.mdc` rules. */
Expand Down Expand Up @@ -247,6 +250,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.commands
? [
'- `commands/` — Claude Code command prompts; Codex has no commands surface; the Cursor manifest deliberately does not point at Claude-format command files.',
]
: []),
...(options.rules
? [
'- `rules/` — Cursor rules (`.mdc`), Cursor only; Claude Code and Codex have no rules surface.',
Expand Down Expand Up @@ -321,6 +329,7 @@ const cursorBundleHookContract = createCursorHookContract({
const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
const diagnostics: Diagnostic[] = [];
const isSelected = (targets: readonly string[]): boolean => targets.includes(pluginName);
const selectedCommands = (model.commands ?? []).filter((command) => isSelected(command.targets));
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.
Expand Down Expand Up @@ -394,6 +403,9 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
}
}
const cursorManifestVariables = cursorVariables(cursorMcp);
// `commands/` contains Claude-generated frontmatter. The pinned Cursor
// evidence establishes plain Markdown commands, but not tolerance for
// Claude frontmatter, so this composite manifest deliberately omits it.
const manifest = cursorManifest(model, {
...(emitCursorHooks ? { hooks: `./${cursorPaths.hooks}` } : {}),
...(cursorMcp !== undefined && cursorMcpValid ? { mcp: `./${cursorPaths.mcp}` } : {}),
Expand Down Expand Up @@ -439,6 +451,7 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
entries.push(...ruleWriteEntries(model, isSelected));
entries.push({
content: agentsDocument(model, {
commands: selectedCommands.length > 0,
lsp: entries.some((entry) => entry.relativePath === claudeArtifactPaths.lsp),
rules: selectedRules.length > 0,
}),
Expand Down Expand Up @@ -476,6 +489,7 @@ export const pluginAdapter: TargetAdapter = Object.freeze({
artifactLayout,
capabilities: Object.freeze({
...compositeEventCapabilities,
commands: intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!),
marketplace: intersectCapabilityStates(claudeAdapter.capabilities.marketplace!, codexAdapter.capabilities.marketplace!),
hooks: intersectCapabilityStates(claudeAdapter.capabilities.hooks!, codexAdapter.capabilities.hooks!),
// Claude supports LSP and Codex has no LSP surface, so the intersection
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 @@ -328,6 +328,9 @@ export const portableAdapter: TargetAdapter = Object.freeze({
}),
capabilities: Object.freeze({
...eventRouteCapabilitiesFrom(capabilityTable.eventRoutes, evidence),
commands: unavailableCapability(
'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.',
),
hooks: unavailableCapability('Agent Plugins 1.0.0 does not define a hooks component.'),
marketplace: unavailableCapability('Agent Plugins 1.0.0 does not define a marketplace document.'),
mcp: capabilityStateFromSupport(
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 @@ -181,6 +181,7 @@ const snapshotArtifactLayout = (
const layout = record(declaredLayout);
if (layout === undefined) throw new Error('Target adapter artifact layout must be a record.');

const commands = layout.commands === undefined ? undefined : snapshotOutputLayout(layout.commands, 'commands');
const hookWrappers = layout.hookWrappers === undefined
? undefined
: snapshotOutputLayout(layout.hookWrappers, 'hook wrappers');
Expand Down Expand Up @@ -215,6 +216,7 @@ const snapshotArtifactLayout = (
}
return Object.freeze({
...(assets === undefined ? {} : { assets }),
...(commands === undefined ? {} : { commands }),
...(hookWrappers === undefined ? {} : { hookWrappers }),
...(mcpApps === undefined ? {} : { mcpApps }),
...(mcpEntries === undefined ? {} : { mcpEntries }),
Expand Down
16 changes: 16 additions & 0 deletions packages/agent-bundle/src/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
pathTokens,
pluginRootEnvAnchor,
type AgentBundleConfig,
type NormalizedCommand,
type NormalizedPlugin,
} from '../core/types.ts';
import type { TargetHookContract, TargetHookEntry } from './hook-contract.ts';
Expand Down Expand Up @@ -132,6 +133,20 @@ export const payloadCopyEntries = (
sourceInputs: sourceInputs(payload.provenance.sourcePath, file.source),
})));

/** Host-specific command writes selected by one target plan. */
export const commandWriteEntries = (
model: NormalizedPlugin,
isSelected: (targets: readonly string[]) => boolean,
serialize: (command: NormalizedCommand) => string,
): TargetArtifactWrite[] => (model.commands ?? [])
.filter((command) => isSelected(command.targets))
.map((command) => ({
content: serialize(command),
kind: 'write',
relativePath: `commands/${command.name}.md`,
sourceInputs: sourceInputs(command.source),
}));

/** Host-emitted write entries for rules selected by one target plan. */
export const ruleWriteEntries = (
model: NormalizedPlugin,
Expand Down Expand Up @@ -393,6 +408,7 @@ const invalidMcpDocumentIssues: readonly TargetArtifactDocumentIssue[] = Object.
*/
export interface TargetArtifactLayout {
readonly assets?: string;
readonly commands?: TargetArtifactOutputLayout;
readonly hookWrappers?: TargetArtifactOutputLayout;
readonly mcpApps?: TargetArtifactOutputLayout;
readonly mcpEntries?: TargetArtifactOutputLayout;
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' | 'rule' | 'script' | 'skill';
readonly kind: 'command' | 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill';
readonly name: string;
readonly reason: InspectionSkipReason;
}
Expand Down Expand Up @@ -461,6 +461,7 @@ interface InspectableComponent {
}

const inspectableComponents = (model: NormalizedPlugin): readonly InspectableComponent[] => [
...(model.commands ?? []).map((command) => ({ capability: 'commands', id: command.id, kind: 'command' as const, name: command.name, targets: command.targets })),
Comment thread
ScriptedAlchemy marked this conversation as resolved.
...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 })),
Expand Down
Loading
Loading