From b67b105c0b8eb6eb987ee9b6fcddde7414a94663 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:33:01 +0000 Subject: [PATCH 1/2] feat(commands): compile conventional command prompts into Cursor and Claude artifacts (#100 stage 2) --- .changeset/host-command-surface.md | 6 + .../adapters/capabilities/claude-2.1.250.json | 2 + .../capabilities/cursor-2026-08-28.json | 2 + packages/agent-bundle/src/adapters/claude.ts | 46 +++- packages/agent-bundle/src/adapters/codex.ts | 3 + packages/agent-bundle/src/adapters/cursor.ts | 30 ++- packages/agent-bundle/src/adapters/plugin.ts | 14 ++ .../agent-bundle/src/adapters/portable.ts | 3 + .../agent-bundle/src/adapters/registry.ts | 2 + packages/agent-bundle/src/adapters/types.ts | 16 ++ .../src/build/validate-artifact.ts | 1 + packages/agent-bundle/src/config/command.ts | 161 ++++++++++++ packages/agent-bundle/src/config/discover.ts | 14 ++ packages/agent-bundle/src/config/index.ts | 3 + packages/agent-bundle/src/config/normalize.ts | 24 ++ packages/agent-bundle/src/config/validate.ts | 76 ++++++ packages/agent-bundle/src/core/types.ts | 18 ++ .../tests/adapter-capability-states.test.ts | 26 +- .../tests/adapter-metadata.test.ts | 8 +- .../tests/artifact-validator.test.ts | 32 ++- .../agent-bundle/tests/command-config.test.ts | 236 ++++++++++++++++++ .../agent-bundle/tests/cursor-adapter.test.ts | 44 ++++ .../agent-bundle/tests/host-adapters.test.ts | 54 ++++ .../agent-bundle/tests/plugin-bundle.test.ts | 40 +++ 24 files changed, 846 insertions(+), 15 deletions(-) create mode 100644 .changeset/host-command-surface.md create mode 100644 packages/agent-bundle/src/config/command.ts create mode 100644 packages/agent-bundle/tests/command-config.test.ts diff --git a/.changeset/host-command-surface.md b/.changeset/host-command-surface.md new file mode 100644 index 000000000..6ffa5158c --- /dev/null +++ b/.changeset/host-command-surface.md @@ -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. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index b843d43ea..37cc31086 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -35,6 +35,7 @@ }, "observedCliVersion": "2.1.250", "plugin": { + "commands": true, "devtools": { "details": true, "listJson": true, @@ -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 ` 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 --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 plugin list --json` for registration proof, `claude plugin details ` 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$.", 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 5d69c05f5..512ce47e6 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 @@ -52,6 +52,7 @@ }, "observedCliVersion": "2026-08-28", "plugin": { + "commands": true, "manifest": ".cursor-plugin/plugin.json", "rules": true, "skills": true, @@ -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." ] diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 4400098be..1429258a5 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -39,10 +39,13 @@ 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, @@ -50,6 +53,7 @@ import { validateModernMcpDocument, withPluginRootEnvAnchor, type TargetAdapter, + type TargetArtifactLayout, type TargetArtifactPlan, } from './types.ts'; @@ -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), }); @@ -175,6 +179,20 @@ const mcpRuntime = createTargetMcpRuntime({ const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(claudeName, 'Claude'); +const claudeCommandMarkdown = ( + command: NonNullable[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}') @@ -469,7 +487,7 @@ export const planClaudeArtifacts = ( diagnostics.push(...schemaDiagnostics('marketplace', marketplaceValid, validateMarketplace.errors)); } - return standardPluginArtifactPlan({ + const basePlan = standardPluginArtifactPlan({ diagnostics, ...(lsp.document === undefined ? {} : { hostDocuments: [{ @@ -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( diff --git a/packages/agent-bundle/src/adapters/codex.ts b/packages/agent-bundle/src/adapters/codex.ts index 4196b1738..7a9102093 100644 --- a/packages/agent-bundle/src/adapters/codex.ts +++ b/packages/agent-bundle/src/adapters/codex.ts @@ -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 diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts index 57ec0e3e2..72315e718 100644 --- a/packages/agent-bundle/src/adapters/cursor.ts +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -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, @@ -234,6 +235,7 @@ export const planCursorMcpServer = ( }; export interface CursorManifestPointers { + readonly commands?: string; readonly hooks?: string; readonly mcp?: string; readonly rules?: string; @@ -246,6 +248,7 @@ export const cursorManifest = ( model: NormalizedPlugin, pointers: CursorManifestPointers, ): Record => ({ + ...(pointers.commands === undefined ? {} : { commands: pointers.commands }), description: model.metadata.description ?? model.metadata.name, displayName: model.metadata.name, ...(pointers.hooks === undefined ? {} : { hooks: pointers.hooks }), @@ -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), }); @@ -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', @@ -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)) { @@ -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/' }), @@ -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, @@ -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), + ]), }); }; @@ -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( diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 16e28572d..f8631f936 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -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, @@ -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. */ @@ -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.', @@ -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. @@ -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}` } : {}), @@ -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, }), @@ -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 diff --git a/packages/agent-bundle/src/adapters/portable.ts b/packages/agent-bundle/src/adapters/portable.ts index d5c7195f1..bcc7aafed 100644 --- a/packages/agent-bundle/src/adapters/portable.ts +++ b/packages/agent-bundle/src/adapters/portable.ts @@ -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( diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index e9e9b9747..ff39e4258 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -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'); @@ -215,6 +216,7 @@ const snapshotArtifactLayout = ( } return Object.freeze({ ...(assets === undefined ? {} : { assets }), + ...(commands === undefined ? {} : { commands }), ...(hookWrappers === undefined ? {} : { hookWrappers }), ...(mcpApps === undefined ? {} : { mcpApps }), ...(mcpEntries === undefined ? {} : { mcpEntries }), diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index f86088303..7717e3699 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -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'; @@ -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, @@ -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; diff --git a/packages/agent-bundle/src/build/validate-artifact.ts b/packages/agent-bundle/src/build/validate-artifact.ts index a8127b9df..53865205b 100644 --- a/packages/agent-bundle/src/build/validate-artifact.ts +++ b/packages/agent-bundle/src/build/validate-artifact.ts @@ -420,6 +420,7 @@ const isTargetArtifactPath = ( const hookContract = registry.hookContract(target); const mcpRuntime = registry.mcpRuntime(target); return isRecursiveArtifactPath(relativePath, layout.assets) || + isDirectOutputLayoutPath(relativePath, layout.commands) || isDirectOutputLayoutPath(relativePath, layout.hookWrappers) || isDirectOutputLayoutPath(relativePath, layout.mcpApps) || isDirectOutputLayoutPath(relativePath, layout.mcpEntries) || diff --git a/packages/agent-bundle/src/config/command.ts b/packages/agent-bundle/src/config/command.ts new file mode 100644 index 000000000..c6b576c35 --- /dev/null +++ b/packages/agent-bundle/src/config/command.ts @@ -0,0 +1,161 @@ +import { readFile } from 'node:fs/promises'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { parseMarkdownFrontmatter } from './skill-references.ts'; + +export interface CommandDocument { + /** Peeled target restriction; never emitted in host command frontmatter. */ + readonly authoredTargets?: readonly string[]; + readonly body: string; + readonly diagnostics: readonly Diagnostic[]; + readonly frontmatter: Readonly>; + /** Exact authored `.md` bytes decoded as UTF-8. */ + 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([ + 'allowedTools', + 'argumentHint', + 'description', + 'disableModelInvocation', + 'model', + '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( + 'AB4922', + `Command frontmatter field ${JSON.stringify(field)} is not supported.`, + source, + )); + } + + const allowedTools = declared.allowedTools; + if (allowedTools !== undefined) { + if (nonemptyString(allowedTools)) { + frontmatter.allowedTools = allowedTools; + } else if (Array.isArray(allowedTools) && allowedTools.every(nonemptyString)) { + frontmatter.allowedTools = [...allowedTools]; + } else { + diagnostics.push(diagnostic( + 'AB4923', + 'Command frontmatter allowedTools must be a nonempty string or an array of nonempty strings.', + source, + )); + } + } + + for (const field of ['argumentHint', 'description', 'model'] as const) { + const value = declared[field]; + if (value === undefined) continue; + if (typeof value === 'string') frontmatter[field] = value; + else diagnostics.push(diagnostic('AB4923', `Command frontmatter ${field} must be a string.`, source)); + } + + const disableModelInvocation = declared.disableModelInvocation; + if (disableModelInvocation !== undefined) { + if (typeof disableModelInvocation === 'boolean') { + frontmatter.disableModelInvocation = disableModelInvocation; + } else { + diagnostics.push(diagnostic( + 'AB4923', + 'Command frontmatter disableModelInvocation 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( + 'AB4923', + 'Command frontmatter targets must be an array of nonempty target names.', + source, + )); + } + } + + return { + ...(authoredTargets === undefined ? {} : { authoredTargets }), + diagnostics, + frontmatter, + }; +}; + +export const parseCommand = async (source: string): Promise => { + let markdown: string; + try { + markdown = await readFile(source, 'utf8'); + } catch (error: unknown) { + return { + body: '', + diagnostics: [diagnostic( + 'AB4920', + `Unable to read command file: ${error instanceof Error ? error.message : String(error)}`, + source, + )], + frontmatter: {}, + markdown: '', + source, + }; + } + + const parsed = parseMarkdownFrontmatter(markdown); + if (parsed.status === 'missing-frontmatter') { + return { + body: parsed.body, + diagnostics: [], + frontmatter: {}, + markdown, + source, + }; + } + if (parsed.status === 'malformed-frontmatter') { + return { + body: parsed.body, + diagnostics: [diagnostic( + 'AB4921', + `Command YAML frontmatter is invalid: ${parsed.message}`, + source, + )], + frontmatter: {}, + markdown, + source, + }; + } + + const validated = validateFrontmatter(parsed.frontmatter, source); + return { + ...(validated.authoredTargets === undefined ? {} : { authoredTargets: validated.authoredTargets }), + body: parsed.body, + diagnostics: validated.diagnostics, + frontmatter: validated.frontmatter, + markdown, + source, + }; +}; diff --git a/packages/agent-bundle/src/config/discover.ts b/packages/agent-bundle/src/config/discover.ts index dae5af1ba..0faf52d62 100644 --- a/packages/agent-bundle/src/config/discover.ts +++ b/packages/agent-bundle/src/config/discover.ts @@ -8,6 +8,7 @@ import { isRecord } from '../core/strict-json.ts'; import type { AgentBundleConfig } from '../core/types.ts'; import { compileRouteGraph, isEmptyRouteGraph } from '../routes/graph.ts'; import type { CompiledRouteGraph } from '../routes/types.ts'; +import { parseCommand, type CommandDocument } from './command.ts'; import { isProjectPathIgnored, readProjectIgnoreRules } from './ignore.ts'; import { isRenderedSkillSourceName } from './rendered-skill.ts'; import { parseRule, type RuleDocument } from './rule.ts'; @@ -40,6 +41,8 @@ export interface DiscoveredPayload { export interface DiscoveredProject { assets?: DiscoveredAsset[]; + /** Conventional flat `commands/*.md` documents; absent when none are discovered. */ + commands?: readonly CommandDocument[]; payloads?: DiscoveredPayload[]; /** Conventional flat `rules/*.mdc` documents; absent when none are discovered. */ rules?: readonly RuleDocument[]; @@ -241,6 +244,16 @@ export const discoverProject = async ( const payloads = await discoverPayloads(projectRoot, config.payload); const routeGraph = await compileRouteGraph(projectRoot, config, rules); + const commandSources = (await fastGlob('commands/*.md', { + absolute: true, + cwd: projectRoot, + dot: true, + followSymbolicLinks: false, + onlyFiles: true, + })) + .filter((source) => !isProjectPathIgnored(rules, projectRoot, source)) + .sort((left, right) => left.localeCompare(right)); + const discoveredCommands = await Promise.all(commandSources.map((source) => parseCommand(source))); const ruleSources = (await fastGlob('rules/*.mdc', { absolute: true, cwd: projectRoot, @@ -253,6 +266,7 @@ export const discoverProject = async ( const discoveredRules = await Promise.all(ruleSources.map((source) => parseRule(source))); return { assets: await discoverAssets(projectRoot, config.assets, rules), + ...(discoveredCommands.length === 0 ? {} : { commands: discoveredCommands }), ...(payloads.length === 0 ? {} : { payloads }), ...(routeGraph === undefined || isEmptyRouteGraph(routeGraph) ? {} : { routeGraph }), ...(discoveredRules.length === 0 ? {} : { rules: discoveredRules }), diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 6423962ae..73d031c9d 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 { parseCommand } from './command.ts'; +export type { CommandDocument } from './command.ts'; export { parseRule } from './rule.ts'; export type { RuleDocument } from './rule.ts'; export { parseSkill } from './skill.ts'; @@ -37,6 +39,7 @@ export type { AgentBundlePrebuiltEntry, NormalizationConfigExtension, NormalizationTargetRegistry, + NormalizedCommand, AgentBundleMcpApp, AgentBundleMcpConfig, AgentBundleMcpServer, diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 173a1b247..07fe47409 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -35,6 +35,7 @@ import type { NormalizationTargetRegistry, NormalizedAsset, NormalizedBinEntry, + NormalizedCommand, NormalizedConfigExtension, NormalizedHook, NormalizedLibEntry, @@ -293,6 +294,7 @@ export const normalizePackageBuild = ( export const reservedPayloadDestinations = Object.freeze(new Set([ 'AGENTS.md', 'assets', + 'commands', 'hooks', 'mcp', 'mcp-apps', @@ -904,6 +906,26 @@ const normalizeAssets = ( targets: [...targetNames], })); +const normalizeCommands = ( + discovered: DiscoveredProject, + targetNames: readonly string[], +): readonly NormalizedCommand[] => (discovered.commands ?? []).map((command) => { + const name = basename(command.source, extname(command.source)); + const targets = command.authoredTargets === undefined + ? [...targetNames] + : sortedUnique(command.authoredTargets.filter((target) => targetNames.includes(target))); + return { + body: command.body, + frontmatter: structuredClone(command.frontmatter), + id: `command:${name}`, + markdown: command.markdown, + name, + provenance: { kind: 'conventional', sourcePath: command.source }, + source: command.source, + targets, + }; +}); + const normalizeRules = ( discovered: DiscoveredProject, targetNames: readonly string[], @@ -986,6 +1008,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 commands = normalizeCommands(discovered, targetNames); const rules = normalizeRules(discovered, targetNames); const packageBuild = normalizePackageBuild( loaded.config, @@ -995,6 +1018,7 @@ export const normalizeProject = async ( ); const model: NormalizedPlugin = { ...(assets.length === 0 ? {} : { assets }), + ...(commands.length === 0 ? {} : { commands }), ...(loaded.config.marketplace === true ? { marketplace: true as const } : {}), extensions: normalizeExtensions(loaded, registry, configProvenance), metadata: { diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 94a436aa1..9a57c9d9b 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -890,6 +890,76 @@ const validateSkill = (skill: SkillDocument): Diagnostic[] => { return diagnostics; }; +const validateCommands = ( + loaded: LoadedConfig, + discovered: DiscoveredProject, + registry: NormalizationTargetRegistry, +): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + const selectedTargets = selectedTargetNamesFor(loaded, registry); + const names = new Map(); + + for (const command of discovered.commands ?? []) { + diagnostics.push(...command.diagnostics); + const name = basename(command.source, extname(command.source)); + const firstSource = names.get(name); + if (firstSource === undefined) { + names.set(name, command.source); + } else { + diagnostics.push(sourceDiagnostic( + 'AB4926', + `Command name ${JSON.stringify(name)} duplicates ${firstSource}.`, + command.source, + )); + } + + for (const target of command.authoredTargets ?? []) { + if (!registry.has(target) || !selectedTargets.includes(target)) { + diagnostics.push({ + code: 'AB4924', + message: `Command ${JSON.stringify(name)} selects target ${JSON.stringify(target)} outside the selected target names.`, + severity: 'error', + sourcePath: command.source, + target, + }); + continue; + } + const capability = registry.capabilityState?.(target, 'commands'); + if (capability === undefined) { + if (registry.supports(target, 'commands')) continue; + diagnostics.push({ + code: 'AB4925', + message: `Command ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose commands capability is unavailable: the target declares no supported commands surface.`, + severity: 'error', + sourcePath: command.source, + target, + }); + continue; + } + switch (capability.state) { + case 'supported': + break; + case 'degraded': + case 'prohibited': + case 'unavailable': + diagnostics.push({ + code: 'AB4925', + message: `Command ${JSON.stringify(name)} explicitly targets ${JSON.stringify(target)}, whose commands capability is ${capability.state}: ${capability.reason}`, + severity: 'error', + sourcePath: command.source, + target, + }); + break; + default: { + const exhaustive: never = capability; + return exhaustive; + } + } + } + } + return diagnostics; +}; + const validateRules = ( loaded: LoadedConfig, discovered: DiscoveredProject, @@ -1699,6 +1769,7 @@ export const validateSource = ( diagnostics.push(...validateMcp(loaded, registry, payloads)); diagnostics.push(...validatePayload(loaded, registry, options?.payloadFreshness !== false)); diagnostics.push(...validateRuntime(loaded)); + diagnostics.push(...validateCommands(loaded, discovered, registry)); diagnostics.push(...validateRules(loaded, discovered, registry)); diagnostics.push(...validateScripts(loaded, registry)); diagnostics.push(...validateTools(loaded)); @@ -1746,6 +1817,7 @@ export const validateModel = ( ...Object.values(model.extensions), ...model.targets, ...model.skills, + ...(model.commands ?? []), ...(model.rules ?? []), ...model.hooks, ...model.mcpServers, @@ -1974,6 +2046,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 command of model.commands ?? []) { + if (!command.targets.includes(target.name)) continue; + recordOutput(posix.join(target.name, 'commands', `${command.name}.md`), command.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); diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index bbdea236f..b7cf87596 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -321,6 +321,19 @@ export interface NormalizedSkill { readonly targets: readonly string[]; } +/** One conventional command prompt with peeled target selection. */ +export interface NormalizedCommand { + readonly body: string; + readonly frontmatter: Readonly>; + readonly id: string; + /** Exact authored bytes decoded as UTF-8 for byte-faithful passthrough. */ + readonly markdown: string; + readonly name: string; + readonly provenance: SourceProvenance; + readonly source: string; + readonly targets: readonly string[]; +} + /** One conventional Cursor `.mdc` rule with peeled target selection. */ export interface NormalizedRule { readonly body: string; @@ -493,6 +506,11 @@ export interface NormalizedPlugin { * it remains optional so hand-constructed models stay valid without assets. */ readonly assets?: readonly NormalizedAsset[]; + /** + * Conventional `commands/*.md` documents. Present only when commands are + * discovered; optional so hand-constructed models predating commands remain valid. + */ + readonly commands?: readonly NormalizedCommand[]; readonly extensions: Readonly>; readonly hooks: readonly NormalizedHook[]; readonly marketplace?: true; diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index 39779a237..3185f2324 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -23,13 +23,35 @@ 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', 'rules', 'skills']) { + for (const capability of ['commands', '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 commands row on every adapter', () => { + const registry = createDefaultRegistry(); + for (const target of ['cursor', 'claude'] as const) { + expect(registry.get(target).capabilities.commands).toMatchObject({ + evidence: { target }, + state: 'supported', + }); + } + expect(registry.get('codex').capabilities.commands).toEqual({ + reason: 'The pinned Codex plugin contract (0.147.0) defines no commands component.', + state: 'unavailable', + }); + expect(registry.get('portable').capabilities.commands).toEqual({ + reason: 'The portable Agent Plugin contract (1.0.0) defines only skills and MCP components; it has no commands surface.', + state: 'unavailable', + }); + expect(registry.get('plugin').capabilities.commands).toEqual(intersectCapabilityStates( + registry.get('claude').capabilities.commands!, + registry.get('codex').capabilities.commands!, + )); +}); + it('records an honest four-state rules row on every adapter', () => { const registry = createDefaultRegistry(); expect(registry.get('cursor').capabilities.rules).toMatchObject({ @@ -205,7 +227,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: '20e93666770d97c0f5c1338de395f326c869684843b3b06bbd7ba3c45a0abc3e', + capabilitySha256: '9884e0ffdb1c7fd1fe6d1071fa6944729d596e51f0ab87ad5ef2b0dd6fd8a981', 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 871525fcc..a46ce2974 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -101,9 +101,9 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.3.0', + adapterRevision: '1.4.0', capabilityRevision: '2.1.250', - capabilitySha256: 'a9c8821ee5cbc6aef65816c5025170389fd490b6d1c4e4e893599aa6a36f2265', + capabilitySha256: '6b8a3b222b49c0ad22f32ecdf8157bd353ce5be05d56e40ae5cf4ad2b9eb917f', observedVersion: '2.1.250', schemas: [ { @@ -134,9 +134,9 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'cursor')).toEqual({ - adapterRevision: '1.3.0', + adapterRevision: '1.4.0', capabilityRevision: '2026-08-28', - capabilitySha256: '20e93666770d97c0f5c1338de395f326c869684843b3b06bbd7ba3c45a0abc3e', + capabilitySha256: '9884e0ffdb1c7fd1fe6d1071fa6944729d596e51f0ab87ad5ef2b0dd6fd8a981', observedVersion: '2026-08-28', schemas: [ { diff --git a/packages/agent-bundle/tests/artifact-validator.test.ts b/packages/agent-bundle/tests/artifact-validator.test.ts index b3518d73f..b9f769405 100644 --- a/packages/agent-bundle/tests/artifact-validator.test.ts +++ b/packages/agent-bundle/tests/artifact-validator.test.ts @@ -187,11 +187,12 @@ const customRegistry = (validate = validateCustomDocument): TargetRegistry => ne }, artifactLayout: { assets: 'assets', + commands: { allowedSuffixes: ['.md'], directory: 'commands' }, rules: { allowedSuffixes: ['.mdc'], directory: 'rules' }, scripts: { allowedSuffixes: ['.json', '.mjs', '.sh'], directory: 'scripts' }, skills: 'skills', }, - capabilities: supportedCapabilities('rules', 'skills'), + capabilities: supportedCapabilities('commands', 'rules', 'skills'), metadata: customMetadata, name: customTarget, plan: () => ({ diagnostics: [], entries: [] }), @@ -254,6 +255,35 @@ it('admits only direct .mdc files in a declared rules layout', async () => { } }); +it('admits only direct .md files in a declared commands 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: '# Command\n', kind: 'generated', path: 'custom/commands/review.md' }, + ], true, [target]); + const invalidRoot = await writeArtifact([ + { contents: '{"kind":"custom"}\n', kind: 'generated', path: 'custom/document.json' }, + { contents: '# Command\n', kind: 'generated', path: 'custom/commands/review.mdc' }, + ], true, [target]); + + try { + expect(await validateArtifact({ artifactRoot: validRoot, registry })).toEqual([]); + expect(await validateArtifact({ artifactRoot: invalidRoot, registry })).toContainEqual( + expect.objectContaining({ + code: 'AB6014', + generatedPath: 'custom/commands/review.mdc', + 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/command-config.test.ts b/packages/agent-bundle/tests/command-config.test.ts new file mode 100644 index 000000000..efc15977c --- /dev/null +++ b/packages/agent-bundle/tests/command-config.test.ts @@ -0,0 +1,236 @@ +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, + parseCommand, + validateSource, + type CommandDocument, + type DiscoveredProject, + type LoadedConfig, +} from '../src/config/index.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; + +const loadedProject = ( + root: string, + targets: readonly string[], +): LoadedConfig => ({ + config: { + plugin: { name: 'command-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-commands-')); + try { + await writeFile( + join(root, 'agent-bundle.config.ts'), + "export default { plugin: { name: 'command-fixture', version: '1.0.0' } };\n", + ); + await run(root); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +it('accepts body-only commands and retains exact authored bytes', async () => { + await withProject(async (root) => { + const source = join(root, 'commands', 'review.md'); + const markdown = '# Review\r\n\r\nCheck the staged diff.'; + await mkdir(join(root, 'commands')); + await writeFile(source, markdown); + + const command = await parseCommand(source); + + expect(command).toMatchObject({ + body: markdown, + diagnostics: [], + frontmatter: {}, + markdown, + source, + }); + expect(command).not.toHaveProperty('authoredTargets'); + }); +}); + +it('peels targets and accepts only the closed canonical frontmatter schema', async () => { + await withProject(async (root) => { + await mkdir(join(root, 'commands')); + const validSource = join(root, 'commands', 'review.md'); + await writeFile( + validSource, + [ + '---', + 'description: Review staged changes', + 'argumentHint: "[path]"', + 'allowedTools:', + ' - Read', + ' - Grep', + 'model: sonnet', + 'disableModelInvocation: true', + 'targets:', + ' - claude', + '---', + '# Review', + '', + ].join('\n'), + ); + const valid = await parseCommand(validSource); + expect(valid.diagnostics).toEqual([]); + expect(valid.frontmatter).toEqual({ + allowedTools: ['Read', 'Grep'], + argumentHint: '[path]', + description: 'Review staged changes', + disableModelInvocation: true, + model: 'sonnet', + }); + expect(valid.authoredTargets).toEqual(['claude']); + + const stringTools = join(root, 'commands', 'string-tools.md'); + await writeFile(stringTools, '---\nallowedTools: Read, Grep\n---\nReview.\n'); + expect((await parseCommand(stringTools)).frontmatter).toEqual({ allowedTools: 'Read, Grep' }); + + const invalidSource = join(root, 'commands', 'invalid.md'); + await writeFile( + invalidSource, + [ + '---', + 'description: 42', + 'argumentHint: false', + 'allowedTools: [""]', + 'model: true', + 'disableModelInvocation: no', + 'targets: claude', + 'extra: hidden', + '---', + '# Invalid', + '', + ].join('\n'), + ); + const invalid = await parseCommand(invalidSource); + expect(invalid.diagnostics.map(({ code }) => code)).toEqual([ + 'AB4922', + 'AB4923', + 'AB4923', + 'AB4923', + 'AB4923', + 'AB4923', + 'AB4923', + ]); + expect(invalid.frontmatter).toEqual({}); + expect(invalid).not.toHaveProperty('authoredTargets'); + }); +}); + +it('reports unreadable files and malformed YAML with fresh command diagnostics', async () => { + await withProject(async (root) => { + const missing = join(root, 'commands', 'missing.md'); + expect((await parseCommand(missing)).diagnostics).toEqual([ + expect.objectContaining({ code: 'AB4920', severity: 'error', sourcePath: missing }), + ]); + + const malformed = join(root, 'commands', 'malformed.md'); + await mkdir(join(root, 'commands')); + await writeFile(malformed, '---\nallowedTools: [unterminated\n---\n# Broken\n'); + expect((await parseCommand(malformed)).diagnostics).toEqual([ + expect.objectContaining({ code: 'AB4921', severity: 'error', sourcePath: malformed }), + ]); + }); +}); + +it('discovers flat non-ignored commands deterministically and omits the collection when empty', async () => { + await withProject(async (root) => { + await mkdir(join(root, 'commands')); + await Promise.all([ + writeFile(join(root, '.gitignore'), 'commands/ignored.md\n'), + writeFile(join(root, 'commands', 'zeta.md'), '# Zeta\n'), + writeFile(join(root, 'commands', 'alpha.md'), '# Alpha\n'), + writeFile(join(root, 'commands', 'ignored.md'), '# Ignored\n'), + ]); + const config: AgentBundleConfig = { + plugin: { name: 'command-fixture', version: '1.0.0' }, + }; + + const discovered = await discoverProject(root, config); + expect(discovered.commands?.map((command) => command.source)).toEqual([ + join(root, 'commands', 'alpha.md'), + join(root, 'commands', 'zeta.md'), + ]); + + await rm(join(root, 'commands'), { recursive: true }); + expect(await discoverProject(root, config)).not.toHaveProperty('commands'); + }); +}); + +it('normalizes peeled targets and reports unknown, unavailable, and duplicate commands', async () => { + await withProject(async (root) => { + const command = ( + source: string, + authoredTargets?: readonly string[], + ): CommandDocument => ({ + ...(authoredTargets === undefined ? {} : { authoredTargets }), + body: '# Command\n', + diagnostics: [], + frontmatter: { description: 'Command prompt' }, + markdown: '---\ndescription: Command prompt\n---\n# Command\n', + source, + }); + const registry = createDefaultRegistry(); + const claudeLoaded = loadedProject(root, ['claude']); + const claudeCommand = command(join(root, 'commands', 'review.md'), ['claude']); + const discovered: DiscoveredProject = { commands: [claudeCommand], skills: [] }; + const model = await normalizeProject(claudeLoaded, discovered, registry); + + expect(model.commands).toEqual([{ + body: '# Command\n', + frontmatter: { description: 'Command prompt' }, + id: 'command:review', + markdown: '---\ndescription: Command prompt\n---\n# Command\n', + name: 'review', + provenance: { kind: 'conventional', sourcePath: claudeCommand.source }, + source: claudeCommand.source, + targets: ['claude'], + }]); + + const unknown = command(join(root, 'commands', 'unknown.md'), ['cursor']); + expect(validateSource(claudeLoaded, { commands: [unknown], skills: [] }, registry)).toEqual([ + expect.objectContaining({ code: 'AB4924', sourcePath: unknown.source }), + ]); + + const codexLoaded = loadedProject(root, ['codex']); + const unavailable = command(join(root, 'commands', 'unavailable.md'), ['codex']); + expect(validateSource(codexLoaded, { commands: [unavailable], skills: [] }, registry)).toEqual([ + expect.objectContaining({ + code: 'AB4925', + message: expect.stringContaining('unavailable'), + sourcePath: unavailable.source, + }), + ]); + + const duplicateFirst = command(join(root, 'first', 'duplicate.md')); + const duplicateSecond = command(join(root, 'second', 'duplicate.md')); + expect(validateSource( + claudeLoaded, + { commands: [duplicateFirst, duplicateSecond], skills: [] }, + registry, + )).toContainEqual(expect.objectContaining({ + code: 'AB4926', + sourcePath: duplicateSecond.source, + })); + }); +}); diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index 6d7ba6284..f2bf1b1a0 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -90,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').commands).toEqual({ + allowedSuffixes: ['.md'], + directory: 'commands', + }); expect(registry.artifactLayout('cursor').rules).toEqual({ allowedSuffixes: ['.mdc'], directory: 'rules', @@ -250,6 +254,46 @@ it('emits selected rules byte-faithfully and omits the entire surface when rule- expect(JSON.parse(writeContents(model)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('rules'); }); +it('emits Cursor command bodies, strips authored frontmatter, and omits the command-free surface', () => { + const model = plugin(); + const withCommands: NormalizedPlugin = { + ...model, + commands: [ + { + body: 'Review the staged diff.\r\n', + frontmatter: { argumentHint: '[path]', description: 'Review changes' }, + id: 'command:review', + markdown: '---\r\ndescription: Review changes\r\nargumentHint: "[path]"\r\n---\r\nReview the staged diff.\r\n', + name: 'review', + provenance: { kind: 'conventional', sourcePath: '/workspace/commands/review.md' }, + source: '/workspace/commands/review.md', + targets: ['cursor'], + }, + { + body: '# Explain\n\nExplain this code.', + frontmatter: {}, + id: 'command:explain', + markdown: '# Explain\n\nExplain this code.', + name: 'explain', + provenance: { kind: 'conventional', sourcePath: '/workspace/commands/explain.md' }, + source: '/workspace/commands/explain.md', + targets: ['cursor'], + }, + ], + }; + + const plan = cursorAdapter.plan(withCommands); + const documents = writeContents(withCommands); + expect(plan.diagnostics).toEqual([]); + expect(documents['commands/review.md']).toBe('Review the staged diff.\r\n'); + expect(documents['commands/explain.md']).toBe('# Explain\n\nExplain this code.'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ commands: './commands/' }); + + const commandFree = cursorAdapter.plan(model); + expect(commandFree.entries.some((entry) => entry.relativePath.startsWith('commands/'))).toBe(false); + expect(JSON.parse(writeContents(model)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('commands'); +}); + 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/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 72c979806..215db42c0 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -205,6 +205,60 @@ it.each(['codex', 'claude'] as const)('copies project assets selected for %s and }]); }); +it('lowers Claude commands with documented kebab-case frontmatter and body-only passthrough', () => { + const model: NormalizedPlugin = { + ...plugin, + commands: [ + { + body: 'Review the staged diff.\n', + frontmatter: { + allowedTools: ['Read', 'Grep'], + argumentHint: '[path]', + description: 'Review changes', + disableModelInvocation: true, + model: 'sonnet', + }, + id: 'command:review', + markdown: 'authored bytes are regenerated for Claude', + name: 'review', + provenance: { kind: 'conventional', sourcePath: '/workspace/commands/review.md' }, + source: '/workspace/commands/review.md', + targets: ['claude'], + }, + { + body: '# Explain\n\nExplain this code.', + frontmatter: {}, + id: 'command:explain', + markdown: '# Explain\n\nExplain this code.', + name: 'explain', + provenance: { kind: 'conventional', sourcePath: '/workspace/commands/explain.md' }, + source: '/workspace/commands/explain.md', + targets: ['claude'], + }, + ], + }; + const documents = writeContents(model, 'claude'); + + expect(documents['commands/review.md']).toBe([ + '---', + 'allowed-tools:', + ' - Read', + ' - Grep', + 'argument-hint: "[path]"', + 'description: Review changes', + 'disable-model-invocation: true', + 'model: sonnet', + '---', + 'Review the staged diff.', + '', + ].join('\n')); + expect(documents['commands/explain.md']).toBe('# Explain\n\nExplain this code.'); + expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('commands'); + + const commandFree = planEntries(plugin, 'claude'); + expect(commandFree.some((entry) => entry.relativePath.startsWith('commands/'))).toBe(false); +}); + it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 83645b1e4..bcf2f3da4 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -282,6 +282,46 @@ it('emits Cursor-only rules once at the shared root and documents the honest hos expect(JSON.parse(writeContents(bundleModel)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('rules'); }); +it('emits Claude-format commands without pointing Cursor at the shared directory', () => { + const model: NormalizedPlugin = { + ...bundleModel, + commands: [{ + body: 'Review the staged diff.\n', + frontmatter: { + argumentHint: '[path]', + description: 'Review changes', + }, + id: 'command:review', + markdown: '---\ndescription: Review changes\nargumentHint: "[path]"\n---\nReview the staged diff.\n', + name: 'review', + provenance: { kind: 'conventional', sourcePath: '/workspace/commands/review.md' }, + source: '/workspace/commands/review.md', + targets: ['plugin'], + }], + }; + const plan = planBundle(model); + const documents = writeContents(model); + + expect(plan.diagnostics).toEqual([]); + expect(documents['commands/review.md']).toBe([ + '---', + 'argument-hint: "[path]"', + 'description: Review changes', + '---', + 'Review the staged diff.', + '', + ].join('\n')); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('commands'); + expect(documents['AGENTS.md']).toContain( + '- `commands/` — Claude Code command prompts; Codex has no commands surface; the Cursor manifest deliberately does not point at Claude-format command files.', + ); + + const commandFree = planBundle(bundleModel); + expect(commandFree.entries.some((entry) => entry.relativePath.startsWith('commands/'))).toBe(false); + expect(writeContents(bundleModel)['AGENTS.md']).not.toContain('`commands/`'); + expect(JSON.parse(writeContents(bundleModel)['.cursor-plugin/plugin.json']!)).not.toHaveProperty('commands'); +}); + 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'); From 35c2a8bcf78a9575813bb33527f423bff3a78846 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Tue, 1 Sep 2026 21:37:15 +0000 Subject: [PATCH 2/2] fix(commands): canonicalize command identity paths and count commands in inspection skips --- packages/agent-bundle/src/api.ts | 3 +- .../agent-bundle/src/core/project-context.ts | 10 ++++ packages/agent-bundle/tests/api.test.ts | 52 +++++++++++++++++-- .../agent-bundle/tests/cursor-adapter.test.ts | 3 +- .../agent-bundle/tests/host-adapters.test.ts | 3 +- 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 5d66e4098..b376b3567 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' | 'rule' | 'script' | 'skill'; + readonly kind: 'command' | 'hook' | 'mcp-app' | 'mcp-server' | 'rule' | 'script' | 'skill'; readonly name: string; readonly reason: InspectionSkipReason; } @@ -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 })), ...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 })), diff --git a/packages/agent-bundle/src/core/project-context.ts b/packages/agent-bundle/src/core/project-context.ts index c296a7ce0..ecc1ebdba 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.commands ?? []).flatMap((command) => [command.provenance.sourcePath, command.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), @@ -338,6 +339,15 @@ export const canonicalizeNormalizedModel = ( source: canonicalCompilerPath(root, asset.source, 'Asset source path'), })), }), + ...(detached.commands === undefined + ? {} + : { + commands: detached.commands.map((command) => ({ + ...command, + provenance: canonicalProvenance(root, command.provenance), + source: canonicalCompilerPath(root, command.source, 'Command source path'), + })), + }), extensions: Object.fromEntries(Object.entries(detached.extensions) .sort(([left], [right]) => left.localeCompare(right)) .map(([key, extension]) => [key, { diff --git a/packages/agent-bundle/tests/api.test.ts b/packages/agent-bundle/tests/api.test.ts index aaec241cb..8533a4034 100644 --- a/packages/agent-bundle/tests/api.test.ts +++ b/packages/agent-bundle/tests/api.test.ts @@ -545,8 +545,16 @@ 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 mkdir(join(root, 'rules')); await Promise.all([ + mkdir(join(root, 'commands')), + mkdir(join(root, 'rules')), + ]); + await Promise.all([ + writeFile(join(root, 'commands', 'shared.md'), '---\ndescription: Shared command\n---\nShared command prompt.\n'), + writeFile( + join(root, 'commands', 'cursor-only.md'), + '---\ndescription: Cursor-only command\ntargets:\n - cursor\n---\nCursor command prompt.\n', + ), 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( @@ -568,16 +576,21 @@ it('reports skipped target/component pairs with intersection-rule reasons', asyn const planFor = (target: string) => result.plans.find((plan) => plan.target === target); expect(planFor('portable')?.skipped).toEqual([ + expect.objectContaining({ kind: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), 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: 'command', name: 'cursor-only', reason: 'excluded-by-targets' }), + expect.objectContaining({ kind: 'command', name: 'shared', reason: 'unsupported-capability' }), 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: 'command', name: 'cursor-only', 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' }), @@ -585,6 +598,9 @@ it('reports skipped target/component pairs with intersection-rule reasons', asyn expect(planFor('cursor')?.skipped).toEqual([ expect.objectContaining({ kind: 'script', name: 'report', reason: 'excluded-by-targets' }), ]); + expect(planFor('claude')?.skipped.some((component) => + component.kind === 'command' && component.name === 'shared')).toBe(false); + expect(planFor('cursor')?.skipped.some((component) => component.kind === 'command')).toBe(false); expect(planFor('cursor')?.skipped.some((component) => component.kind === 'rule')).toBe(false); expect(Object.isFrozen(planFor('portable')?.skipped)).toBe(true); } finally { @@ -799,7 +815,7 @@ 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 () => { +it('keeps rule and command model digests root-independent and sensitive to content', async () => { const [leftRoot, rightRoot] = await Promise.all([createProject(), createProject()]); const config = [ 'export default {', @@ -818,14 +834,30 @@ it('keeps rule model digests root-independent and sensitive to rule content', as '', ].join('\n'); const sharedRule = '---\ndescription: Shared guidance\n---\nShared body.\n'; + const targetedCommand = [ + '---', + 'description: Cursor-only command', + 'targets:', + ' - cursor', + '---', + 'Targeted command body.', + '', + ].join('\n'); + const sharedCommand = '---\ndescription: Shared command\n---\nShared command body.\n'; try { await Promise.all([ + mkdir(join(leftRoot, 'commands')), + mkdir(join(rightRoot, 'commands')), 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, 'commands', 'cursor-only.md'), targetedCommand), + writeFile(join(rightRoot, 'commands', 'cursor-only.md'), targetedCommand), + writeFile(join(leftRoot, 'commands', 'shared.md'), sharedCommand), + writeFile(join(rightRoot, 'commands', 'shared.md'), sharedCommand), writeFile(join(leftRoot, 'rules', 'cursor-only.mdc'), targetedRule), writeFile(join(rightRoot, 'rules', 'cursor-only.mdc'), targetedRule), writeFile(join(leftRoot, 'rules', 'shared.mdc'), sharedRule), @@ -838,13 +870,25 @@ it('keeps rule model digests root-independent and sensitive to rule content', as ]); expect(left.projectContext.modelDigest).toBe(right.projectContext.modelDigest); expect(left.projectContext.sourceInputs.map((input) => input.path)).toEqual(expect.arrayContaining([ + 'commands/cursor-only.md', + 'commands/shared.md', '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); + const changedRule = await readyInspection({ root: rightRoot }); + expect(changedRule.projectContext.modelDigest).not.toBe(left.projectContext.modelDigest); + + await Promise.all([ + writeFile(join(rightRoot, 'rules', 'shared.mdc'), sharedRule), + writeFile( + join(rightRoot, 'commands', 'shared.md'), + sharedCommand.replace('Shared command body.', 'Changed command body.'), + ), + ]); + const changedCommand = await readyInspection({ root: rightRoot }); + expect(changedCommand.projectContext.modelDigest).not.toBe(left.projectContext.modelDigest); } finally { await Promise.all([ rm(join(leftRoot, '..'), { force: true, recursive: true }), diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts index f2bf1b1a0..62b1cd24d 100644 --- a/packages/agent-bundle/tests/cursor-adapter.test.ts +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -263,7 +263,7 @@ it('emits Cursor command bodies, strips authored frontmatter, and omits the comm body: 'Review the staged diff.\r\n', frontmatter: { argumentHint: '[path]', description: 'Review changes' }, id: 'command:review', - markdown: '---\r\ndescription: Review changes\r\nargumentHint: "[path]"\r\n---\r\nReview the staged diff.\r\n', + markdown: '---\r\ndescription: Review changes\r\nargumentHint: "[path]"\r\ntargets:\r\n - cursor\r\n---\r\nReview the staged diff.\r\n', name: 'review', provenance: { kind: 'conventional', sourcePath: '/workspace/commands/review.md' }, source: '/workspace/commands/review.md', @@ -286,6 +286,7 @@ it('emits Cursor command bodies, strips authored frontmatter, and omits the comm const documents = writeContents(withCommands); expect(plan.diagnostics).toEqual([]); expect(documents['commands/review.md']).toBe('Review the staged diff.\r\n'); + expect(documents['commands/review.md']).not.toContain('targets:'); expect(documents['commands/explain.md']).toBe('# Explain\n\nExplain this code.'); expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toMatchObject({ commands: './commands/' }); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 215db42c0..d9f83ff14 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -219,7 +219,7 @@ it('lowers Claude commands with documented kebab-case frontmatter and body-only model: 'sonnet', }, id: 'command:review', - markdown: 'authored bytes are regenerated for Claude', + markdown: '---\ndescription: Review changes\ntargets:\n - claude\n---\nReview the staged diff.\n', name: 'review', provenance: { kind: 'conventional', sourcePath: '/workspace/commands/review.md' }, source: '/workspace/commands/review.md', @@ -252,6 +252,7 @@ it('lowers Claude commands with documented kebab-case frontmatter and body-only 'Review the staged diff.', '', ].join('\n')); + expect(documents['commands/review.md']).not.toContain('targets:'); expect(documents['commands/explain.md']).toBe('# Explain\n\nExplain this code.'); expect(JSON.parse(documents['.claude-plugin/plugin.json']!)).not.toHaveProperty('commands');