diff --git a/.changeset/skill-ir-token-model.md b/.changeset/skill-ir-token-model.md new file mode 100644 index 000000000..b036edc34 --- /dev/null +++ b/.changeset/skill-ir-token-model.md @@ -0,0 +1,7 @@ +--- +"agent-bundle": minor +--- + +Compile one Skill source into a canonical IR with a typed plugin-surface token registry and closed per-host lowering (#108). + +Portable `SKILL.md` stays a byte-stable pass-through when no host extension or placeholder requires target-specific output. Claude, Cursor, and Codex receive only schema-legal documents (Claude frontmatter extensions, Cursor path/invocation fields, Codex `agents/openai.yaml`); unsupported tokens and unknown fields fail with AB3006–AB3010. Shared-vs-per-host `skills/` layout is an inspect-visible evidence decision for #101, not a hard-committed install tree. Rendered skills keep the existing `SKILL.tsx`/`SKILL.ts` build-time path — no live Flight client. diff --git a/packages/agent-bundle/src/adapters/types.ts b/packages/agent-bundle/src/adapters/types.ts index 205147241..8f0145017 100644 --- a/packages/agent-bundle/src/adapters/types.ts +++ b/packages/agent-bundle/src/adapters/types.ts @@ -255,7 +255,25 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): } for (const skill of input.sharedCopyEntries === false ? [] : model.skills) { if (!isSelected(skill.targets)) continue; - if (skill.markdown !== undefined) { + const hostDocument = skill.hostDocuments?.[targetName]; + const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough; + if (generatedSkill) { + entries.push({ + content: hostDocument.skillMarkdown, + kind: 'write', + relativePath: `skills/${skill.name}/SKILL.md`, + sourceInputs: sourceInputs(skill.source), + }); + for (const sidecar of hostDocument.sidecars) { + if (sidecar.content === undefined) continue; + entries.push({ + content: sidecar.content.endsWith('\n') ? sidecar.content : `${sidecar.content}\n`, + kind: 'write', + relativePath: `skills/${skill.name}/${sidecar.relativePath}`, + sourceInputs: sourceInputs(skill.source, sidecar.source), + }); + } + } else if (skill.markdown !== undefined) { // A rendered skill's SKILL.md is compiled from its component module. entries.push({ content: skill.markdown, @@ -264,7 +282,11 @@ export const standardPluginArtifactPlan = (input: StandardPluginArtifactsInput): sourceInputs: sourceInputs(skill.source), }); } + const skipCopies = new Set(generatedSkill + ? ['SKILL.md', ...hostDocument.sidecars.map((sidecar) => sidecar.relativePath)] + : []); for (const resource of skill.resources) { + if (skipCopies.has(resource.relativePath)) continue; entries.push({ bytes: resource.bytes, kind: 'copy', diff --git a/packages/agent-bundle/src/api.ts b/packages/agent-bundle/src/api.ts index 42b7fdfa5..453e351eb 100644 --- a/packages/agent-bundle/src/api.ts +++ b/packages/agent-bundle/src/api.ts @@ -254,6 +254,10 @@ export interface ReadyInspectResult { readonly hooks?: NormalizedPlugin['hooks']; readonly routes?: RouteGraphInspection; readonly skills?: NormalizedPlugin['skills']; + readonly skillTreeLayouts?: readonly { + readonly layout?: NormalizedPlugin['skills'][number]['skillTreeLayout']; + readonly skillId: string; + }[]; }; readonly state: 'ready'; } @@ -527,7 +531,15 @@ export const inspect = async (options: InspectOptions): Promise = ...(bundler === undefined ? {} : { bundler }), ...(options.focus === 'hooks' ? { hooks: model.hooks } : {}), ...(routes === undefined ? {} : { routes }), - ...(options.focus === 'skills' ? { skills: model.skills } : {}), + ...(options.focus === 'skills' + ? { + skills: model.skills, + skillTreeLayouts: Object.freeze(model.skills.map((skill) => Object.freeze({ + skillId: skill.id, + ...(skill.skillTreeLayout === undefined ? {} : { layout: skill.skillTreeLayout }), + }))), + } + : {}), }); return Object.freeze({ diagnostics: prepared.diagnostics, diff --git a/packages/agent-bundle/src/build/validate-artifact-skills.ts b/packages/agent-bundle/src/build/validate-artifact-skills.ts index ae6113345..1f4fc75f9 100644 --- a/packages/agent-bundle/src/build/validate-artifact-skills.ts +++ b/packages/agent-bundle/src/build/validate-artifact-skills.ts @@ -5,6 +5,10 @@ import type { TargetRegistry } from '../adapters/registry.ts'; import { parseSkillMarkdown, referencedResources } from '../config/skill-references.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts'; +import { + validateClaudeSkillFrontmatter, + validateCursorSkillFrontmatter, +} from '../schemas/skill-hosts/contract.ts'; import { artifactDiagnostic as diagnostic, artifactDiagnosticRecoveries, @@ -142,7 +146,12 @@ export const validateEmittedSkills = async (options: { continue; } - for (const issue of validateAgentSkillsFrontmatter(parsed.frontmatter)) { + const frontmatterIssues = skill.target === 'claude' + ? validateClaudeSkillFrontmatter(parsed.frontmatter) + : skill.target === 'cursor' + ? validateCursorSkillFrontmatter(parsed.frontmatter) + : validateAgentSkillsFrontmatter(parsed.frontmatter); + for (const issue of frontmatterIssues) { const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath); diagnostics.push(diagnostic( 'AB6015', diff --git a/packages/agent-bundle/src/config/index.ts b/packages/agent-bundle/src/config/index.ts index 05e53055a..53a5083ac 100644 --- a/packages/agent-bundle/src/config/index.ts +++ b/packages/agent-bundle/src/config/index.ts @@ -13,6 +13,11 @@ export type { LoadedConfig, LoadConfigOptions } from './load.ts'; export { normalizeProject } from './normalize.ts'; export { parseSkill } from './skill.ts'; export type { SkillDocument, SkillResource } from './skill.ts'; +export { defineSkill, Skill } from '../skills/define.ts'; +export { inspectSkillProjection } from '../skills/inspect.ts'; +export { parseSkillIr } from '../skills/parse-ir.ts'; +export { lowerSkillIr } from '../skills/lower.ts'; +export type { SkillIr, SkillHostDocument, SkillTreeLayoutDecision } from '../skills/ir.ts'; export { validateModel, validateSource } from './validate.ts'; export type AgentBundleConfig = CoreAgentBundleConfig & ClaudeConfigExtension diff --git a/packages/agent-bundle/src/config/normalize.ts b/packages/agent-bundle/src/config/normalize.ts index 7495dfd29..c2aa957c5 100644 --- a/packages/agent-bundle/src/config/normalize.ts +++ b/packages/agent-bundle/src/config/normalize.ts @@ -53,8 +53,37 @@ import type { CompiledCliSurface } from '../routes/types.ts'; import { type DiscoveredProject, payloadDeclarationSource } from './discover.ts'; import type { LoadedConfig } from './load.ts'; import type { CanonicalAgentEvent } from '../routes/public.ts'; +import type { SkillIr } from '../skills/ir.ts'; +import { decideSkillTreeLayout, lowerSkillIr, lowerSkillIrForHosts } from '../skills/lower.ts'; +import { parseSkillIr } from '../skills/parse-ir.ts'; +import type { SkillHost } from '../skills/tokens.ts'; import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './script-routes.ts'; +const isSkillHost = (name: string): name is SkillHost => + name === 'claude' || name === 'codex' || name === 'cursor' || name === 'portable'; + +const loweringHosts = (targetNames: readonly string[]): SkillHost[] => { + const hosts = new Set(); + for (const name of targetNames) { + if (name === 'plugin') { + hosts.add('claude'); + hosts.add('codex'); + } else if (isSkillHost(name)) { + hosts.add(name); + } + } + return [...hosts]; +}; + +const pluginSharedDocument = (skillIr: SkillIr) => { + const claude = lowerSkillIr(skillIr, 'claude'); + const codex = lowerSkillIr(skillIr, 'codex'); + if (claude.passThrough && codex.passThrough && claude.skillMarkdown === codex.skillMarkdown) { + return claude; + } + return lowerSkillIr(skillIr, 'portable'); +}; + const unique = (values: readonly string[]): string[] => [...new Set(values)]; const sortedUnique = (values: readonly string[]): string[] => @@ -886,6 +915,7 @@ export const normalizeProject = async ( kind: 'config', sourcePath: loaded.configPath, }; + const skillHosts = loweringHosts(targetNames); const skills: NormalizedSkill[] = discovered.skills.map((skill) => { const frontmatter = structuredClone(skill.frontmatter); const declaredName = frontmatter.name; @@ -894,17 +924,25 @@ export const normalizeProject = async ( ? declaredName : basename(skill.dir); const description = frontmatter.description; + const skillIr = parseSkillIr(skill); + const hostDocuments = { + ...lowerSkillIrForHosts(skillIr, skillHosts), + ...(targetNames.includes('plugin') ? { plugin: pluginSharedDocument(skillIr) } : {}), + }; return { body: skill.body, ...(typeof description === 'string' ? { description } : {}), dir: skill.dir, frontmatter, + hostDocuments, id: `skill:${name}`, ...(skill.rendered === true ? { markdown: skill.markdown } : {}), name, provenance: skillProvenance(loaded, skill.source), resources: skill.resources.map((resource) => ({ ...resource })), + skillIr, + skillTreeLayout: decideSkillTreeLayout(hostDocuments), source: skill.source, targets: [...targetNames], }; diff --git a/packages/agent-bundle/src/config/rendered-skill.ts b/packages/agent-bundle/src/config/rendered-skill.ts index 75d94b3a0..90727dabc 100644 --- a/packages/agent-bundle/src/config/rendered-skill.ts +++ b/packages/agent-bundle/src/config/rendered-skill.ts @@ -34,6 +34,7 @@ export const isRenderedSkillSourceName = (fileName: string): boolean => (renderedSkillFileNames as readonly string[]).includes(fileName); export interface CompiledRenderedSkill { + readonly authoredTargets?: unknown; readonly body: string; readonly frontmatter: Record; /** The full compiled document: YAML frontmatter followed by the rendered body. */ @@ -120,8 +121,15 @@ export const compileRenderedSkill = async (source: string): Promise => { @@ -137,6 +139,9 @@ const parseRenderedSkill = async ( }; } return { + ...(compiled.document.authoredTargets === undefined + ? {} + : { authoredTargets: compiled.document.authoredTargets }), body: compiled.document.body, diagnostics: [], dir, diff --git a/packages/agent-bundle/src/config/validate.ts b/packages/agent-bundle/src/config/validate.ts index 7c158b575..e0e781b1a 100644 --- a/packages/agent-bundle/src/config/validate.ts +++ b/packages/agent-bundle/src/config/validate.ts @@ -42,6 +42,7 @@ import { configuredScriptNames, judgeScriptRoute, scriptRouteName } from './scri import type { SkillDocument } from './skill.ts'; import { referencedResources } from './skill-references.ts'; import { validateAgentSkillsFrontmatter } from '../schemas/agent-skills/contract.ts'; +import { parseSkillIr } from '../skills/parse-ir.ts'; const sourceDiagnostic = ( code: string, @@ -831,10 +832,18 @@ const validateMcp = ( }; const validateSkill = (skill: SkillDocument): Diagnostic[] => { - const diagnostics = [...skill.diagnostics]; - const name = skill.frontmatter.name; - - diagnostics.push(...validateAgentSkillsFrontmatter(skill.frontmatter).map((issue) => { + const ir = parseSkillIr(skill); + const diagnostics = [...ir.diagnostics]; + const name = ir.portable.name ?? skill.frontmatter.name; + + diagnostics.push(...validateAgentSkillsFrontmatter({ + ...(ir.portable.allowedTools === undefined ? {} : { 'allowed-tools': ir.portable.allowedTools }), + ...(ir.portable.compatibility === undefined ? {} : { compatibility: ir.portable.compatibility }), + ...(ir.portable.description === undefined ? {} : { description: ir.portable.description }), + ...(ir.portable.license === undefined ? {} : { license: ir.portable.license }), + ...(ir.portable.metadata === undefined ? {} : { metadata: ir.portable.metadata }), + ...(ir.portable.name === undefined ? {} : { name: ir.portable.name }), + }).map((issue) => { const location = issue.field ?? (issue.instancePath === '' ? 'root' : issue.instancePath); return sourceDiagnostic( issue.field === 'name' ? 'AB4002' : issue.field === 'description' ? 'AB4003' : 'AB4007', @@ -1693,6 +1702,14 @@ export const validateModel = ( } } + for (const skill of model.skills) { + for (const document of Object.values(skill.hostDocuments ?? {})) { + diagnostics.push(...document.diagnostics.filter((diagnostic) => + diagnostic.code === 'AB3008' || diagnostic.code === 'AB3009' || diagnostic.code === 'AB3010', + )); + } + } + for (const hook of model.hooks) { for (const target of hook.targets) { if (!registry.has(target)) { @@ -1849,14 +1866,35 @@ export const validateModel = ( }; for (const target of model.targets) { for (const skill of model.skills) { - if (skill.markdown !== undefined) { + const hostDocument = skill.hostDocuments?.[target.name]; + const generatedSkill = hostDocument !== undefined && !hostDocument.passThrough; + if (generatedSkill) { + recordOutput( + posix.join(target.name, 'skills', skill.name, 'SKILL.md'), + skill.source, + target.name, + ); + for (const sidecar of hostDocument.sidecars) { + recordOutput( + posix.join(target.name, 'skills', skill.name, sidecar.relativePath), + sidecar.source ?? skill.source, + target.name, + ); + } + } else if (skill.markdown !== undefined) { recordOutput( posix.join(target.name, 'skills', skill.name, 'SKILL.md'), skill.source, target.name, ); } + const generatedSidecars = new Set( + generatedSkill ? hostDocument.sidecars.map((sidecar) => sidecar.relativePath) : [], + ); for (const resource of skill.resources) { + if (generatedSkill && (resource.relativePath === 'SKILL.md' || generatedSidecars.has(resource.relativePath))) { + continue; + } recordOutput( posix.join(target.name, 'skills', skill.name, resource.relativePath), resource.source, diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index ba050cf21..dd562529a 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -6,6 +6,7 @@ import type { CanonicalAgentEvent, } from '../routes/public.ts'; import type { CompiledAgentRoute, CompiledCliCommand } from '../routes/types.ts'; +import type { SkillHostDocument, SkillIr, SkillTreeLayoutDecision } from '../skills/ir.ts'; import type { CapabilityState } from './capabilities.ts'; export interface AgentBundlePluginConfig { @@ -299,6 +300,11 @@ export interface NormalizedSkill { readonly description?: string; readonly dir: string; readonly frontmatter: Readonly>; + /** + * Per-host lowered Skill documents. The artifact planner emits these + * instead of the authored bytes when `passThrough` is false. + */ + readonly hostDocuments?: Readonly>; readonly id: string; /** * The compiled SKILL.md document of a rendered skill (`SKILL.tsx` @@ -309,6 +315,8 @@ export interface NormalizedSkill { readonly name: string; readonly provenance: SourceProvenance; readonly resources: readonly NormalizedSkillResource[]; + readonly skillIr?: SkillIr; + readonly skillTreeLayout?: SkillTreeLayoutDecision; readonly source: string; readonly targets: readonly string[]; } diff --git a/packages/agent-bundle/src/index.ts b/packages/agent-bundle/src/index.ts index a9af62252..329c63eee 100644 --- a/packages/agent-bundle/src/index.ts +++ b/packages/agent-bundle/src/index.ts @@ -4,6 +4,21 @@ import type { PortableConfigExtension } from './adapters/portable.ts'; import type { AgentBundleConfig as CoreAgentBundleConfig } from './core/types.ts'; export { defineConfig, pathTokens, pluginRootEnvAnchor } from './core/types.ts'; +export { defineSkill, Skill } from './skills/define.ts'; +export { + classifySkillToken, + skillTokenSpellings, +} from './skills/tokens.ts'; +export type { + ClaudeSkillExtension, + CodexSkillExtension, + CursorSkillExtension, + DefinedSkill, + SkillHost, + SkillIr, + SkillTokenId, + SkillTreeLayoutDecision, +} from './skills/index.ts'; export { canonicalAgentEvents } from './routes/public.ts'; export type { AgentEventCanonicalIdentity, diff --git a/packages/agent-bundle/src/schemas/skill-hosts/PROVENANCE.json b/packages/agent-bundle/src/schemas/skill-hosts/PROVENANCE.json new file mode 100644 index 000000000..7a19d4f8e --- /dev/null +++ b/packages/agent-bundle/src/schemas/skill-hosts/PROVENANCE.json @@ -0,0 +1,28 @@ +{ + "derivedSchemas": { + "claude-skill-frontmatter.schema.json": { + "bytes": 1837, + "sha256": "9c21ad7aaf73625e2b6d4e7da2fe96fb2d2b9e872c4bdfd7d2d37a7726d47221", + "url": "https://code.claude.com/docs/en/skills" + }, + "codex-openai-yaml.schema.json": { + "bytes": 1387, + "sha256": "39e7ebf8eb004fd99b2cdf4b45019c37d464269fca6f2d4297f91a6b58be3d1a", + "url": "https://learn.chatgpt.com/docs/build-skills" + }, + "cursor-skill-frontmatter.schema.json": { + "bytes": 1269, + "sha256": "f1fc5befced2cdb8a74af4859bd8fdd25266bfa4994b6884a6e42b22f960a40f", + "url": "https://prod.cursor.com/docs/skills" + } + }, + "normativeTextWinsOnConflict": true, + "notes": "Closed, documentation-derived Skill host schemas for #108. Claude fields are the Claude Code Skill frontmatter extensions documented against the 2.1.250 adapter pin. Cursor fields are the Skills page (paths, disable-model-invocation, icon, color, legacy globs) against the 2026-08-28 Cursor pin; plugin-config ${VAR} interpolation is intentionally absent from Skill Markdown. Codex agents/openai.yaml is the documented interface/policy/dependencies sidecar; Codex documents no Skill Markdown interpolation engine. These schemas are not host-published machine-readable artifacts; they reject unknown fields the way the portable Agent Skills schema does.", + "observedVersions": { + "claude": "2.1.250", + "codex": "0.147.0", + "cursor": "2026-08-28" + }, + "retrievedAt": "2026-09-01", + "validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators." +} diff --git a/packages/agent-bundle/src/schemas/skill-hosts/claude-skill-frontmatter.schema.json b/packages/agent-bundle/src/schemas/skill-hosts/claude-skill-frontmatter.schema.json new file mode 100644 index 000000000..639e75432 --- /dev/null +++ b/packages/agent-bundle/src/schemas/skill-hosts/claude-skill-frontmatter.schema.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/skill-hosts/claude-skill-frontmatter.schema.json", + "title": "Claude Code Skill frontmatter", + "type": "object", + "additionalProperties": false, + "required": ["name", "description"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "description": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "\\S" }, + "license": { "type": "string" }, + "compatibility": { "type": "string", "minLength": 1, "maxLength": 500 }, + "metadata": { "type": "object", "additionalProperties": { "type": "string" } }, + "allowed-tools": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "when_to_use": { "type": "string" }, + "argument-hint": { "type": "string" }, + "arguments": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "disable-model-invocation": { "type": "boolean" }, + "user-invocable": { "type": "boolean" }, + "disallowed-tools": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "model": { "type": "string", "minLength": 1 }, + "effort": { "type": "string", "enum": ["low", "medium", "high", "xhigh", "max"] }, + "context": { "type": "string", "const": "fork" }, + "agent": { "type": "string" }, + "background": { "type": "boolean" }, + "hooks": { "type": "object" }, + "paths": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "shell": { "type": "string", "enum": ["bash", "powershell"] } + } +} diff --git a/packages/agent-bundle/src/schemas/skill-hosts/codex-openai-yaml.schema.json b/packages/agent-bundle/src/schemas/skill-hosts/codex-openai-yaml.schema.json new file mode 100644 index 000000000..1f85a7d50 --- /dev/null +++ b/packages/agent-bundle/src/schemas/skill-hosts/codex-openai-yaml.schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/skill-hosts/codex-openai-yaml.schema.json", + "title": "Codex agents/openai.yaml", + "type": "object", + "additionalProperties": false, + "properties": { + "interface": { + "type": "object", + "additionalProperties": false, + "properties": { + "display_name": { "type": "string" }, + "short_description": { "type": "string" }, + "icon_small": { "type": "string" }, + "icon_large": { "type": "string" }, + "brand_color": { "type": "string" }, + "default_prompt": { "type": "string" } + } + }, + "policy": { + "type": "object", + "additionalProperties": false, + "properties": { + "allow_implicit_invocation": { "type": "boolean" } + } + }, + "dependencies": { + "type": "object", + "additionalProperties": false, + "properties": { + "tools": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string" }, + "value": { "type": "string" }, + "description": { "type": "string" }, + "transport": { "type": "string" }, + "url": { "type": "string" } + } + } + } + } + } + } +} diff --git a/packages/agent-bundle/src/schemas/skill-hosts/contract.ts b/packages/agent-bundle/src/schemas/skill-hosts/contract.ts new file mode 100644 index 000000000..05bf92998 --- /dev/null +++ b/packages/agent-bundle/src/schemas/skill-hosts/contract.ts @@ -0,0 +1,76 @@ +import { Ajv2020, type ErrorObject } from 'ajv/dist/2020.js'; + +import { validateAgentSkillsFrontmatter } from '../agent-skills/contract.ts'; +import claudeSchema from './claude-skill-frontmatter.schema.json' with { type: 'json' }; +import codexSchema from './codex-openai-yaml.schema.json' with { type: 'json' }; +import cursorSchema from './cursor-skill-frontmatter.schema.json' with { type: 'json' }; +import provenance from './PROVENANCE.json' with { type: 'json' }; + +export interface SkillHostDocumentIssue { + readonly field?: string; + readonly instancePath: string; + readonly keyword: string; + readonly message: string; +} + +interface SkillHostProvenance { + readonly derivedSchemas: Readonly>; + readonly retrievedAt: string; +} + +const schemaProvenance = provenance as SkillHostProvenance; +const validator = new Ajv2020({ allErrors: true, strict: true }); +const validateClaude = validator.compile(claudeSchema); +const validateCursor = validator.compile(cursorSchema); +const validateCodex = validator.compile(codexSchema); + +const parameter = (error: ErrorObject, name: string): string | undefined => { + const value = (error.params as Record)[name]; + return typeof value === 'string' ? value : undefined; +}; + +const fieldFor = (error: ErrorObject): string | undefined => { + if (error.keyword === 'additionalProperties') return parameter(error, 'additionalProperty'); + if (error.keyword === 'required') return parameter(error, 'missingProperty'); + const [field] = error.instancePath + .split('/') + .filter((segment) => segment.length > 0) + .map((segment) => segment.replaceAll('~1', '/').replaceAll('~0', '~')); + return field; +}; + +const toIssue = (error: ErrorObject): SkillHostDocumentIssue => { + const field = fieldFor(error); + return Object.freeze({ + ...(field === undefined ? {} : { field }), + instancePath: error.instancePath, + keyword: error.keyword, + message: error.message ?? 'schema validation failed', + }); +}; + +const issuesFrom = ( + valid: boolean, + errors: readonly ErrorObject[] | null | undefined, +): readonly SkillHostDocumentIssue[] => { + if (valid) return Object.freeze([]); + return Object.freeze((errors ?? []).map(toIssue)); +}; + +export const skillHostSchemaRevision = Object.freeze({ + claudeSha256: schemaProvenance.derivedSchemas['claude-skill-frontmatter.schema.json']?.sha256, + codexSha256: schemaProvenance.derivedSchemas['codex-openai-yaml.schema.json']?.sha256, + cursorSha256: schemaProvenance.derivedSchemas['cursor-skill-frontmatter.schema.json']?.sha256, + retrievedAt: schemaProvenance.retrievedAt, +}); + +export const validateClaudeSkillFrontmatter = (value: unknown): readonly SkillHostDocumentIssue[] => + issuesFrom(validateClaude(value), validateClaude.errors); + +export const validateCursorSkillFrontmatter = (value: unknown): readonly SkillHostDocumentIssue[] => + issuesFrom(validateCursor(value), validateCursor.errors); + +export const validateCodexOpenaiYaml = (value: unknown): readonly SkillHostDocumentIssue[] => + issuesFrom(validateCodex(value), validateCodex.errors); + +export const validatePortableSkillFrontmatter = validateAgentSkillsFrontmatter; diff --git a/packages/agent-bundle/src/schemas/skill-hosts/cursor-skill-frontmatter.schema.json b/packages/agent-bundle/src/schemas/skill-hosts/cursor-skill-frontmatter.schema.json new file mode 100644 index 000000000..2ad472e79 --- /dev/null +++ b/packages/agent-bundle/src/schemas/skill-hosts/cursor-skill-frontmatter.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://agent-bundle.dev/schemas/skill-hosts/cursor-skill-frontmatter.schema.json", + "title": "Cursor Skill frontmatter", + "type": "object", + "additionalProperties": false, + "required": ["name", "description"], + "properties": { + "name": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "description": { "type": "string", "minLength": 1, "maxLength": 1024, "pattern": "\\S" }, + "license": { "type": "string" }, + "compatibility": { "type": "string", "minLength": 1, "maxLength": 500 }, + "metadata": { "type": "object", "additionalProperties": { "type": "string" } }, + "allowed-tools": { "type": "string" }, + "paths": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "disable-model-invocation": { "type": "boolean" }, + "icon": { "type": "string" }, + "color": { + "type": "string", + "enum": ["default", "green", "cyan", "blue", "purple", "magenta", "orange", "yellow", "red", "brand"] + }, + "globs": { + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + } + } +} diff --git a/packages/agent-bundle/src/skills/define.ts b/packages/agent-bundle/src/skills/define.ts new file mode 100644 index 000000000..f6083b1ad --- /dev/null +++ b/packages/agent-bundle/src/skills/define.ts @@ -0,0 +1,32 @@ +import type { ClaudeSkillExtension, CodexSkillExtension, CursorSkillExtension } from './ir.ts'; +import { skillTokenSpellings } from './tokens.ts'; + +export interface DefinedSkillTargets { + readonly claude?: ClaudeSkillExtension; + readonly codex?: CodexSkillExtension; + readonly cursor?: CursorSkillExtension; +} + +export interface DefinedSkill { + readonly description: string; + readonly name: string; + readonly targets?: DefinedSkillTargets; +} + +/** Identity helper so authors can type a Skill definition next to a rendered source. */ +export const defineSkill = (skill: Skill): Skill => skill; + +/** + * Rendered-skill components that emit canonical tokens. The Markdown renderer + * resolves function components to strings; host syntax is applied only during + * lowering, never here. + */ +export const Skill = Object.freeze({ + Arguments: (): string => skillTokenSpellings.arguments, + PluginData: (): string => skillTokenSpellings.pluginData, + PluginRoot: (): string => skillTokenSpellings.pluginRoot, + ProjectRoot: (): string => skillTokenSpellings.projectRoot, + Resource: ({ path }: { readonly path: string }): string => `[${path}](${path})`, + SessionIdentity: (): string => skillTokenSpellings.sessionIdentity, + SkillRoot: (): string => skillTokenSpellings.skillRoot, +}); diff --git a/packages/agent-bundle/src/skills/index.ts b/packages/agent-bundle/src/skills/index.ts new file mode 100644 index 000000000..b6749740f --- /dev/null +++ b/packages/agent-bundle/src/skills/index.ts @@ -0,0 +1,32 @@ +export { defineSkill, Skill } from './define.ts'; +export type { DefinedSkill, DefinedSkillTargets } from './define.ts'; +export { inspectSkillProjection } from './inspect.ts'; +export type { SkillProjectionInspection } from './inspect.ts'; +export type { + ClaudeSkillExtension, + CodexSkillExtension, + CodexSkillToolDependency, + CursorSkillExtension, + PortableSkillMetadata, + SkillHostDocument, + SkillIr, + SkillIrExtensions, + SkillTokenLoweringRecord, + SkillTreeLayoutDecision, +} from './ir.ts'; +export { decideSkillTreeLayout, lowerSkillIr, lowerSkillIrForHosts } from './lower.ts'; +export { parseSkillIr } from './parse-ir.ts'; +export { + classifySkillToken, + findSkillTokens, + skillTokenAliases, + skillTokenSpellings, +} from './tokens.ts'; +export type { + SkillDocumentKind, + SkillHost, + SkillTokenClass, + SkillTokenClassification, + SkillTokenId, + SkillTokenOccurrence, +} from './tokens.ts'; diff --git a/packages/agent-bundle/src/skills/inspect.ts b/packages/agent-bundle/src/skills/inspect.ts new file mode 100644 index 000000000..82ba9d6d9 --- /dev/null +++ b/packages/agent-bundle/src/skills/inspect.ts @@ -0,0 +1,29 @@ +import { deepFreeze } from '../core/freeze.ts'; +import type { SkillHostDocument, SkillIr, SkillTokenLoweringRecord, SkillTreeLayoutDecision } from './ir.ts'; +import { decideSkillTreeLayout, lowerSkillIrForHosts } from './lower.ts'; +import type { SkillHost } from './tokens.ts'; + +export interface SkillProjectionInspection { + readonly authoredMarkdown: string; + readonly authoredSource: string; + readonly hostDocuments: Readonly>; + readonly skillTreeLayout: SkillTreeLayoutDecision; + readonly tokenLowering: readonly SkillTokenLoweringRecord[]; +} + +export const inspectSkillProjection = ( + ir: SkillIr, + hosts: readonly SkillHost[], +): SkillProjectionInspection => { + const hostDocuments = lowerSkillIrForHosts(ir, hosts); + const tokenLowering = Object.freeze( + hosts.flatMap((host) => hostDocuments[host]?.tokenLowering ?? []), + ); + return deepFreeze({ + authoredMarkdown: ir.markdown, + authoredSource: ir.source, + hostDocuments, + skillTreeLayout: decideSkillTreeLayout(hostDocuments), + tokenLowering, + }); +}; diff --git a/packages/agent-bundle/src/skills/ir.ts b/packages/agent-bundle/src/skills/ir.ts new file mode 100644 index 000000000..0cf9cf2ba --- /dev/null +++ b/packages/agent-bundle/src/skills/ir.ts @@ -0,0 +1,124 @@ +import type { Diagnostic } from '../core/diagnostics.ts'; +import type { SkillTokenId, SkillTokenOccurrence } from './tokens.ts'; + +export interface PortableSkillMetadata { + readonly allowedTools?: string; + readonly compatibility?: string; + readonly description?: string; + readonly license?: string; + readonly metadata?: Readonly>; + readonly name?: string; +} + +export interface ClaudeSkillExtension { + readonly agent?: string; + readonly allowedTools?: string | readonly string[]; + readonly argumentHint?: string; + readonly arguments?: readonly string[]; + readonly background?: boolean; + readonly context?: 'fork'; + readonly disableModelInvocation?: boolean; + readonly disallowedTools?: string | readonly string[]; + readonly effort?: 'high' | 'low' | 'max' | 'medium' | 'xhigh'; + readonly hooks?: Readonly>; + readonly model?: string; + readonly paths?: readonly string[]; + readonly shell?: 'bash' | 'powershell'; + readonly userInvocable?: boolean; + readonly whenToUse?: string; +} + +export interface CursorSkillExtension { + readonly color?: string; + readonly disableModelInvocation?: boolean; + readonly globs?: string | readonly string[]; + readonly icon?: string; + readonly paths?: readonly string[]; +} + +export interface CodexSkillToolDependency { + readonly description?: string; + readonly transport?: string; + readonly type?: string; + readonly url?: string; + readonly value?: string; +} + +export interface CodexSkillExtension { + readonly dependencies?: { + readonly tools?: readonly CodexSkillToolDependency[]; + }; + readonly interface?: { + readonly brandColor?: string; + readonly defaultPrompt?: string; + readonly displayName?: string; + readonly iconLarge?: string; + readonly iconSmall?: string; + readonly shortDescription?: string; + }; + readonly policy?: { + readonly allowImplicitInvocation?: boolean; + }; +} + +export interface SkillIrExtensions { + readonly claude?: ClaudeSkillExtension; + readonly codex?: CodexSkillExtension; + readonly cursor?: CursorSkillExtension; +} + +export interface SkillSidecarRef { + readonly content?: string; + readonly relativePath: string; + readonly source?: string; +} + +export interface SkillResourceRef { + readonly bytes: number; + readonly relativePath: string; + readonly source: string; +} + +export interface SkillIrPlaceholder extends SkillTokenOccurrence { + readonly required: true; +} + +export interface SkillIr { + readonly authoredTargets?: unknown; + readonly body: string; + readonly diagnostics: readonly Diagnostic[]; + readonly extensions: SkillIrExtensions; + readonly markdown: string; + readonly passThrough: boolean; + readonly placeholders: readonly SkillIrPlaceholder[]; + readonly portable: PortableSkillMetadata; + readonly resources: readonly SkillResourceRef[]; + readonly sidecars: readonly SkillSidecarRef[]; + readonly source: string; +} + +export interface SkillTreeLayoutDecision { + readonly decision: 'per-host-required' | 'shared'; + readonly evidence: string; + readonly feeds: '#101'; + readonly reason: string; +} + +export interface SkillHostDocument { + readonly diagnostics: readonly Diagnostic[]; + readonly frontmatter: Readonly>; + readonly passThrough: boolean; + readonly sidecars: readonly SkillSidecarRef[]; + readonly skillMarkdown: string; + readonly target: string; + readonly tokenLowering: readonly SkillTokenLoweringRecord[]; +} + +export interface SkillTokenLoweringRecord { + readonly alias: string; + readonly class: 'none' | 'portable'; + readonly document: 'skill-markdown'; + readonly host: string; + readonly syntax?: string; + readonly token: SkillTokenId; +} diff --git a/packages/agent-bundle/src/skills/lower.ts b/packages/agent-bundle/src/skills/lower.ts new file mode 100644 index 000000000..6a01d855e --- /dev/null +++ b/packages/agent-bundle/src/skills/lower.ts @@ -0,0 +1,275 @@ +import { stringify as stringifyYaml } from 'yaml'; + +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import { + validateAgentSkillsFrontmatter, +} from '../schemas/agent-skills/contract.ts'; +import { + validateClaudeSkillFrontmatter, + validateCodexOpenaiYaml, + validateCursorSkillFrontmatter, + type SkillHostDocumentIssue, +} from '../schemas/skill-hosts/contract.ts'; +import type { + ClaudeSkillExtension, + CodexSkillExtension, + CursorSkillExtension, + PortableSkillMetadata, + SkillHostDocument, + SkillIr, + SkillSidecarRef, + SkillTokenLoweringRecord, + SkillTreeLayoutDecision, +} from './ir.ts'; +import { + classifySkillToken, + foreignSkillMarkdownSyntax, + replaceSkillTokens, + type SkillHost, +} from './tokens.ts'; + +const omitUndefined = (record: Record): Record => + Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); + +const portableFrontmatter = (portable: PortableSkillMetadata): Record => omitUndefined({ + 'allowed-tools': portable.allowedTools, + compatibility: portable.compatibility, + description: portable.description, + license: portable.license, + metadata: portable.metadata, + name: portable.name, +}); + +const claudeFrontmatter = ( + portable: PortableSkillMetadata, + extension: ClaudeSkillExtension | undefined, +): Record => omitUndefined({ + ...portableFrontmatter(portable), + agent: extension?.agent, + 'allowed-tools': extension?.allowedTools ?? portable.allowedTools, + 'argument-hint': extension?.argumentHint, + arguments: extension?.arguments, + background: extension?.background, + context: extension?.context, + 'disable-model-invocation': extension?.disableModelInvocation, + 'disallowed-tools': extension?.disallowedTools, + effort: extension?.effort, + hooks: extension?.hooks, + model: extension?.model, + paths: extension?.paths, + shell: extension?.shell, + 'user-invocable': extension?.userInvocable, + when_to_use: extension?.whenToUse, +}); + +const cursorFrontmatter = ( + portable: PortableSkillMetadata, + extension: CursorSkillExtension | undefined, +): Record => omitUndefined({ + ...portableFrontmatter(portable), + color: extension?.color, + 'disable-model-invocation': extension?.disableModelInvocation, + globs: extension?.globs, + icon: extension?.icon, + paths: extension?.paths, +}); + +const codexSidecarDocument = (extension: CodexSkillExtension): Record => omitUndefined({ + ...(extension.dependencies === undefined ? {} : { + dependencies: omitUndefined({ + tools: extension.dependencies.tools?.map((tool) => omitUndefined({ ...tool })), + }), + }), + ...(extension.interface === undefined ? {} : { + interface: omitUndefined({ + brand_color: extension.interface.brandColor, + default_prompt: extension.interface.defaultPrompt, + display_name: extension.interface.displayName, + icon_large: extension.interface.iconLarge, + icon_small: extension.interface.iconSmall, + short_description: extension.interface.shortDescription, + }), + }), + ...(extension.policy === undefined ? {} : { + policy: omitUndefined({ + allow_implicit_invocation: extension.policy.allowImplicitInvocation, + }), + }), +}); + +const rebuildMarkdown = (frontmatter: Record, body: string): string => + `---\n${stringifyYaml(frontmatter)}---\n${body.startsWith('\n') ? body : `\n${body}`}`; + +const schemaIssues = ( + host: SkillHost, + issues: readonly SkillHostDocumentIssue[], + source: string, +): Diagnostic[] => issues.map((issue) => ({ + code: 'AB3010', + message: `Lowered ${host} Skill document ${issue.field ?? (issue.instancePath || 'root')} ${issue.message}.`, + recovery: 'Remove the unsupported field or restrict the skill to a host that documents it.', + severity: 'error' as const, + sourcePath: source, + target: host, +})); + +const validateFrontmatter = ( + host: SkillHost, + frontmatter: Record, + source: string, +): Diagnostic[] => { + switch (host) { + case 'claude': + return schemaIssues(host, validateClaudeSkillFrontmatter(frontmatter), source); + case 'cursor': + return schemaIssues(host, validateCursorSkillFrontmatter(frontmatter), source); + case 'codex': + case 'portable': + return schemaIssues(host, validateAgentSkillsFrontmatter(frontmatter), source); + default: { + const exhaustive: never = host; + return exhaustive; + } + } +}; + +const lowerBody = ( + ir: SkillIr, + host: SkillHost, +): { readonly body: string; readonly diagnostics: Diagnostic[]; readonly tokenLowering: SkillTokenLoweringRecord[] } => { + const diagnostics: Diagnostic[] = []; + const tokenLowering: SkillTokenLoweringRecord[] = ir.placeholders.map((placeholder) => { + const classification = classifySkillToken(placeholder.token, host, 'skill-markdown'); + const record: SkillTokenLoweringRecord = { + alias: placeholder.alias, + class: classification.class === 'none' ? 'none' : 'portable', + document: 'skill-markdown', + host, + ...(classification.syntax === undefined ? {} : { syntax: classification.syntax }), + token: placeholder.token, + }; + if (classification.class === 'none') { + diagnostics.push({ + code: 'AB3008', + message: `Skill token ${JSON.stringify(placeholder.token)} has no ${host} Skill Markdown equivalent.`, + recovery: 'Remove the token, restrict the skill to a host that documents it, or move the reference to a document that host interpolates.', + severity: 'error', + sourcePath: ir.source, + target: host, + }); + } + return record; + }); + + const body = replaceSkillTokens(ir.body, (occurrence) => { + const classification = classifySkillToken(occurrence.token, host, 'skill-markdown'); + return classification.syntax ?? ''; + }); + + for (const syntax of foreignSkillMarkdownSyntax(host)) { + if (body.includes(syntax)) { + diagnostics.push({ + code: 'AB3009', + message: `Lowered ${host} Skill Markdown contains foreign host syntax ${JSON.stringify(syntax)}.`, + recovery: 'Use canonical agent-bundle tokens so lowering emits only this host\'s documented placeholders.', + severity: 'error', + sourcePath: ir.source, + target: host, + }); + } + } + + return { body, diagnostics, tokenLowering }; +}; + +export const lowerSkillIr = (ir: SkillIr, host: SkillHost): SkillHostDocument => { + if (ir.passThrough) { + return deepFreeze({ + diagnostics: [], + frontmatter: { ...portableFrontmatter(ir.portable) }, + passThrough: true, + sidecars: [], + skillMarkdown: ir.markdown, + target: host, + tokenLowering: [], + }); + } + + const lowered = lowerBody(ir, host); + const diagnostics: Diagnostic[] = [...ir.diagnostics, ...lowered.diagnostics]; + const sidecars: SkillSidecarRef[] = []; + let frontmatter: Record; + + switch (host) { + case 'claude': + frontmatter = claudeFrontmatter(ir.portable, ir.extensions.claude); + break; + case 'cursor': + frontmatter = cursorFrontmatter(ir.portable, ir.extensions.cursor); + break; + case 'codex': + frontmatter = portableFrontmatter(ir.portable); + if (ir.extensions.codex !== undefined) { + const sidecar = codexSidecarDocument(ir.extensions.codex); + diagnostics.push(...schemaIssues(host, validateCodexOpenaiYaml(sidecar), ir.source)); + sidecars.push({ + content: stringifyYaml(sidecar), + relativePath: 'agents/openai.yaml', + }); + } + break; + case 'portable': + frontmatter = portableFrontmatter(ir.portable); + break; + default: { + const exhaustive: never = host; + return exhaustive; + } + } + + diagnostics.push(...validateFrontmatter(host, frontmatter, ir.source)); + return deepFreeze({ + diagnostics: Object.freeze(diagnostics), + frontmatter, + passThrough: false, + sidecars: Object.freeze(sidecars), + skillMarkdown: rebuildMarkdown(frontmatter, lowered.body), + target: host, + tokenLowering: Object.freeze(lowered.tokenLowering), + }); +}; + +export const decideSkillTreeLayout = ( + documents: Readonly>, +): SkillTreeLayoutDecision => { + const markdown = Object.values(documents) + .filter((document): document is SkillHostDocument => document !== undefined) + .map((document) => document.skillMarkdown); + const sidecars = Object.values(documents) + .filter((document): document is SkillHostDocument => document !== undefined) + .some((document) => document.sidecars.length > 0); + const unique = new Set(markdown); + if (unique.size <= 1 && !sidecars) { + return Object.freeze({ + decision: 'shared', + evidence: 'Lowered Skill Markdown is byte-identical across the selected hosts and no host sidecar is required.', + feeds: '#101', + reason: 'A shared skills/ tree is valid only while every selected host receives the same document bytes.', + }); + } + return Object.freeze({ + decision: 'per-host-required', + evidence: sidecars + ? 'At least one host requires a sidecar (for example Codex agents/openai.yaml) or a different Skill Markdown document.' + : 'Selected hosts lower to different Skill Markdown bytes (frontmatter extensions or placeholder syntax).', + feeds: '#101', + reason: 'Do not claim one shared skills/ file is valid without semantic identity. Install-time selection is #101.', + }); +}; + +export const lowerSkillIrForHosts = ( + ir: SkillIr, + hosts: readonly SkillHost[], +): Readonly> => + Object.freeze(Object.fromEntries(hosts.map((host) => [host, lowerSkillIr(ir, host)]))); diff --git a/packages/agent-bundle/src/skills/parse-ir.ts b/packages/agent-bundle/src/skills/parse-ir.ts new file mode 100644 index 000000000..8beaf1e3d --- /dev/null +++ b/packages/agent-bundle/src/skills/parse-ir.ts @@ -0,0 +1,319 @@ +import type { SkillDocument } from '../config/skill.ts'; +import type { Diagnostic } from '../core/diagnostics.ts'; +import { deepFreeze } from '../core/freeze.ts'; +import type { + ClaudeSkillExtension, + CodexSkillExtension, + CursorSkillExtension, + PortableSkillMetadata, + SkillIr, + SkillIrExtensions, + SkillIrPlaceholder, + SkillSidecarRef, +} from './ir.ts'; +import { findSkillTokens } from './tokens.ts'; + +const portableKeys = new Set(['allowed-tools', 'compatibility', 'description', 'license', 'metadata', 'name']); +const claudeOnlyKeys = new Set([ + 'agent', + 'argument-hint', + 'arguments', + 'background', + 'context', + 'disallowed-tools', + 'effort', + 'hooks', + 'model', + 'shell', + 'user-invocable', + 'when_to_use', +]); +const sharedKeys = new Set(['disable-model-invocation', 'paths']); +const cursorOnlyKeys = new Set(['color', 'globs', 'icon']); +const authoringKeys = new Set(['targets']); + +const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const asString = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +const asBoolean = (value: unknown): boolean | undefined => + typeof value === 'boolean' ? value : undefined; + +const asStringList = (value: unknown): readonly string[] | undefined => { + if (typeof value === 'string') { + return Object.freeze(value.split(',').map((entry) => entry.trim()).filter((entry) => entry.length > 0)); + } + if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) { + return Object.freeze([...value]); + } + return undefined; +}; + +const asStringOrList = (value: unknown): string | readonly string[] | undefined => { + if (typeof value === 'string') return value; + if (Array.isArray(value) && value.every((entry) => typeof entry === 'string')) return Object.freeze([...value]); + return undefined; +}; + +const metadataRecord = (value: unknown): Readonly> | undefined => { + if (!isPlainRecord(value)) return undefined; + const entries = Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string'); + return entries.length === 0 ? undefined : Object.freeze(Object.fromEntries(entries)); +}; + +const portableFrom = (frontmatter: Readonly>): PortableSkillMetadata => { + const allowedTools = frontmatter['allowed-tools']; + const allowedToolList = typeof allowedTools === 'string' ? undefined : asStringList(allowedTools); + const portable: PortableSkillMetadata = { + ...(typeof allowedTools === 'string' ? { allowedTools } : {}), + ...(allowedToolList === undefined ? {} : { allowedTools: allowedToolList.join(' ') }), + ...(asString(frontmatter.compatibility) === undefined ? {} : { compatibility: asString(frontmatter.compatibility) }), + ...(asString(frontmatter.description) === undefined ? {} : { description: asString(frontmatter.description) }), + ...(asString(frontmatter.license) === undefined ? {} : { license: asString(frontmatter.license) }), + ...(metadataRecord(frontmatter.metadata) === undefined ? {} : { metadata: metadataRecord(frontmatter.metadata) }), + ...(asString(frontmatter.name) === undefined ? {} : { name: asString(frontmatter.name) }), + }; + return Object.freeze(portable); +}; + +const claudeFrom = (fields: Readonly>): ClaudeSkillExtension | undefined => { + const extension: ClaudeSkillExtension = { + ...(asString(fields.agent) === undefined ? {} : { agent: asString(fields.agent) }), + ...(asStringOrList(fields['allowed-tools']) === undefined + ? {} + : { allowedTools: asStringOrList(fields['allowed-tools']) }), + ...(asString(fields['argument-hint']) === undefined ? {} : { argumentHint: asString(fields['argument-hint']) }), + ...(asStringList(fields.arguments) === undefined ? {} : { arguments: asStringList(fields.arguments) }), + ...(asBoolean(fields.background) === undefined ? {} : { background: asBoolean(fields.background) }), + ...(fields.context === 'fork' ? { context: 'fork' as const } : {}), + ...(asBoolean(fields['disable-model-invocation']) === undefined + ? {} + : { disableModelInvocation: asBoolean(fields['disable-model-invocation']) }), + ...(asStringOrList(fields['disallowed-tools']) === undefined + ? {} + : { disallowedTools: asStringOrList(fields['disallowed-tools']) }), + ...(fields.effort === 'low' || fields.effort === 'medium' || fields.effort === 'high' + || fields.effort === 'xhigh' || fields.effort === 'max' + ? { effort: fields.effort } + : {}), + ...(isPlainRecord(fields.hooks) ? { hooks: Object.freeze({ ...fields.hooks }) } : {}), + ...(asString(fields.model) === undefined ? {} : { model: asString(fields.model) }), + ...(asStringList(fields.paths) === undefined ? {} : { paths: asStringList(fields.paths) }), + ...(fields.shell === 'bash' || fields.shell === 'powershell' ? { shell: fields.shell } : {}), + ...(asBoolean(fields['user-invocable']) === undefined ? {} : { userInvocable: asBoolean(fields['user-invocable']) }), + ...(asString(fields.when_to_use) === undefined ? {} : { whenToUse: asString(fields.when_to_use) }), + }; + return Object.keys(extension).length === 0 ? undefined : Object.freeze(extension); +}; + +const cursorFrom = (fields: Readonly>): CursorSkillExtension | undefined => { + const extension: CursorSkillExtension = { + ...(asString(fields.color) === undefined ? {} : { color: asString(fields.color) }), + ...(asBoolean(fields['disable-model-invocation']) === undefined + ? {} + : { disableModelInvocation: asBoolean(fields['disable-model-invocation']) }), + ...(asStringOrList(fields.globs) === undefined ? {} : { globs: asStringOrList(fields.globs) }), + ...(asString(fields.icon) === undefined ? {} : { icon: asString(fields.icon) }), + ...(asStringList(fields.paths) === undefined ? {} : { paths: asStringList(fields.paths) }), + }; + return Object.keys(extension).length === 0 ? undefined : Object.freeze(extension); +}; + +const pickString = (record: Readonly>, camel: string, snake: string): string | undefined => + asString(record[camel]) ?? asString(record[snake]); + +const pickBoolean = (record: Readonly>, camel: string, snake: string): boolean | undefined => + asBoolean(record[camel]) ?? asBoolean(record[snake]); + +const codexFrom = (value: unknown): CodexSkillExtension | undefined => { + if (!isPlainRecord(value)) return undefined; + const iface = isPlainRecord(value.interface) ? value.interface : undefined; + const policy = isPlainRecord(value.policy) ? value.policy : undefined; + const dependencies = isPlainRecord(value.dependencies) ? value.dependencies : undefined; + const tools = Array.isArray(dependencies?.tools) + ? dependencies.tools.filter(isPlainRecord).map((tool) => Object.freeze({ + ...(asString(tool.description) === undefined ? {} : { description: asString(tool.description) }), + ...(asString(tool.transport) === undefined ? {} : { transport: asString(tool.transport) }), + ...(asString(tool.type) === undefined ? {} : { type: asString(tool.type) }), + ...(asString(tool.url) === undefined ? {} : { url: asString(tool.url) }), + ...(asString(tool.value) === undefined ? {} : { value: asString(tool.value) }), + })) + : undefined; + const extension: CodexSkillExtension = { + ...(iface === undefined ? {} : { + interface: Object.freeze({ + ...(pickString(iface, 'brandColor', 'brand_color') === undefined + ? {} + : { brandColor: pickString(iface, 'brandColor', 'brand_color') }), + ...(pickString(iface, 'defaultPrompt', 'default_prompt') === undefined + ? {} + : { defaultPrompt: pickString(iface, 'defaultPrompt', 'default_prompt') }), + ...(pickString(iface, 'displayName', 'display_name') === undefined + ? {} + : { displayName: pickString(iface, 'displayName', 'display_name') }), + ...(pickString(iface, 'iconLarge', 'icon_large') === undefined + ? {} + : { iconLarge: pickString(iface, 'iconLarge', 'icon_large') }), + ...(pickString(iface, 'iconSmall', 'icon_small') === undefined + ? {} + : { iconSmall: pickString(iface, 'iconSmall', 'icon_small') }), + ...(pickString(iface, 'shortDescription', 'short_description') === undefined + ? {} + : { shortDescription: pickString(iface, 'shortDescription', 'short_description') }), + }), + }), + ...(policy === undefined ? {} : { + policy: Object.freeze({ + ...(pickBoolean(policy, 'allowImplicitInvocation', 'allow_implicit_invocation') === undefined + ? {} + : { allowImplicitInvocation: pickBoolean(policy, 'allowImplicitInvocation', 'allow_implicit_invocation') }), + }), + }), + ...(tools === undefined ? {} : { dependencies: Object.freeze({ tools: Object.freeze(tools) }) }), + }; + return Object.keys(extension).length === 0 ? undefined : Object.freeze(extension); +}; + +const mergeClaude = ( + left: ClaudeSkillExtension | undefined, + right: ClaudeSkillExtension | undefined, +): ClaudeSkillExtension | undefined => { + if (left === undefined) return right; + if (right === undefined) return left; + return Object.freeze({ ...left, ...right }); +}; + +const mergeCursor = ( + left: CursorSkillExtension | undefined, + right: CursorSkillExtension | undefined, +): CursorSkillExtension | undefined => { + if (left === undefined) return right; + if (right === undefined) return left; + return Object.freeze({ ...left, ...right }); +}; + +const unknownField = (source: string, field: string): Diagnostic => ({ + code: 'AB3006', + message: `Skill frontmatter field ${JSON.stringify(field)} is not a portable Agent Skills field or a typed host extension.`, + recovery: 'Move host-only fields into `targets.` or a documented host key, or remove the unknown field.', + severity: 'error', + sourcePath: source, +}); + +const sidecarFromResource = (document: SkillDocument): SkillSidecarRef | undefined => { + const resource = document.resources.find((entry) => entry.relativePath === 'agents/openai.yaml'); + if (resource === undefined) return undefined; + return Object.freeze({ + relativePath: resource.relativePath, + source: resource.source, + }); +}; + +const peelTargets = ( + value: unknown, + source: string, + diagnostics: Diagnostic[], +): SkillIrExtensions => { + if (value === undefined) return {}; + if (!isPlainRecord(value)) { + diagnostics.push({ + code: 'AB3006', + message: 'Skill `targets` must be an object with optional `claude`, `cursor`, and `codex` keys.', + recovery: 'Replace `targets` with a typed per-host object.', + severity: 'error', + sourcePath: source, + }); + return {}; + } + const unknown = Object.keys(value).filter((key) => key !== 'claude' && key !== 'codex' && key !== 'cursor'); + for (const key of unknown) diagnostics.push(unknownField(source, `targets.${key}`)); + const claude = isPlainRecord(value.claude) + ? claudeFrom({ + ...value.claude, + 'argument-hint': value.claude.argumentHint ?? value.claude['argument-hint'], + 'disable-model-invocation': value.claude.disableModelInvocation ?? value.claude['disable-model-invocation'], + 'disallowed-tools': value.claude.disallowedTools ?? value.claude['disallowed-tools'], + 'user-invocable': value.claude.userInvocable ?? value.claude['user-invocable'], + when_to_use: value.claude.whenToUse ?? value.claude.when_to_use, + 'allowed-tools': value.claude.allowedTools ?? value.claude['allowed-tools'], + }) + : undefined; + const cursor = isPlainRecord(value.cursor) + ? cursorFrom({ + ...value.cursor, + 'disable-model-invocation': value.cursor.disableModelInvocation ?? value.cursor['disable-model-invocation'], + }) + : undefined; + const codex = codexFrom(value.codex); + return { + ...(claude === undefined ? {} : { claude }), + ...(codex === undefined ? {} : { codex }), + ...(cursor === undefined ? {} : { cursor }), + }; +}; + +export const parseSkillIr = (document: SkillDocument): SkillIr => { + const diagnostics: Diagnostic[] = [...document.diagnostics]; + const frontmatter = document.frontmatter; + const unknownKeys = Object.keys(frontmatter).filter((key) => + !portableKeys.has(key) && + !claudeOnlyKeys.has(key) && + !sharedKeys.has(key) && + !cursorOnlyKeys.has(key) && + !authoringKeys.has(key) + ); + for (const key of unknownKeys) diagnostics.push(unknownField(document.source, key)); + + const peeledClaude: Record = {}; + const peeledCursor: Record = {}; + for (const [key, value] of Object.entries(frontmatter)) { + if (claudeOnlyKeys.has(key) || sharedKeys.has(key)) peeledClaude[key] = value; + if (cursorOnlyKeys.has(key) || sharedKeys.has(key)) peeledCursor[key] = value; + } + + const fromFrontmatter: SkillIrExtensions = { + ...(claudeFrom(peeledClaude) === undefined ? {} : { claude: claudeFrom(peeledClaude) }), + ...(cursorFrom(peeledCursor) === undefined ? {} : { cursor: cursorFrom(peeledCursor) }), + }; + const fromTargets = peelTargets(frontmatter.targets ?? document.authoredTargets, document.source, diagnostics); + const extensions: SkillIrExtensions = Object.freeze({ + ...(mergeClaude(fromFrontmatter.claude, fromTargets.claude) === undefined + ? {} + : { claude: mergeClaude(fromFrontmatter.claude, fromTargets.claude) }), + ...(mergeCursor(fromFrontmatter.cursor, fromTargets.cursor) === undefined + ? {} + : { cursor: mergeCursor(fromFrontmatter.cursor, fromTargets.cursor) }), + ...(fromTargets.codex === undefined ? {} : { codex: fromTargets.codex }), + }); + + const placeholders: SkillIrPlaceholder[] = findSkillTokens(document.body).map((occurrence) => + Object.freeze({ ...occurrence, required: true as const }), + ); + const sidecar = sidecarFromResource(document); + const hasExtensions = extensions.claude !== undefined || + extensions.codex !== undefined || + extensions.cursor !== undefined; + const passThrough = diagnostics.every((diagnostic) => diagnostic.severity !== 'error') && + !hasExtensions && + placeholders.length === 0; + + return deepFreeze({ + ...(document.authoredTargets === undefined ? {} : { authoredTargets: document.authoredTargets }), + body: document.body, + diagnostics: Object.freeze(diagnostics), + extensions, + markdown: document.markdown, + passThrough, + placeholders: Object.freeze(placeholders), + portable: portableFrom(frontmatter), + resources: Object.freeze(document.resources.map((resource) => Object.freeze({ + bytes: resource.bytes, + relativePath: resource.relativePath, + source: resource.source, + }))), + sidecars: Object.freeze(sidecar === undefined ? [] : [sidecar]), + source: document.source, + }); +}; diff --git a/packages/agent-bundle/src/skills/tokens.ts b/packages/agent-bundle/src/skills/tokens.ts new file mode 100644 index 000000000..f36594188 --- /dev/null +++ b/packages/agent-bundle/src/skills/tokens.ts @@ -0,0 +1,241 @@ +/** + * Canonical Skill / plugin-surface tokens. Build-time lowering substitutes + * host syntax only; runtime values are never resolved here. + * + * Plugin/project-root spellings are the same bytes as `pathTokens` in + * `core/types.ts` so MCP, hooks, and skills share one registry (#108 / #107 R12). + * This module must not import `core/types.ts` — NormalizedSkill lives there + * and imports the Skill IR types. + */ +export const skillTokenSpellings = Object.freeze({ + arguments: 'agent-bundle:token:arguments', + pluginData: 'agent-bundle:path:plugin-data', + pluginRoot: 'agent-bundle:path:plugin-root', + projectRoot: 'agent-bundle:path:workspace-root', + sessionIdentity: 'agent-bundle:token:session-identity', + skillRoot: 'agent-bundle:token:skill-root', +} as const); + +export type SkillTokenId = keyof typeof skillTokenSpellings; + +export type SkillHost = 'claude' | 'codex' | 'cursor' | 'portable'; + +export type SkillDocumentKind = + | 'commands' + | 'hooks' + | 'mcp' + | 'plugin-config' + | 'prompts' + | 'skill-frontmatter' + | 'skill-markdown'; + +export type SkillTokenClass = 'namespaced' | 'none' | 'portable'; + +export interface SkillTokenClassification { + readonly class: SkillTokenClass; + readonly document: SkillDocumentKind; + readonly evidence: string; + readonly host: SkillHost; + readonly syntax?: string; + readonly token: SkillTokenId; +} + +const claudeSkills = 'https://code.claude.com/docs/en/skills (Claude Code 2.1.250 pin)'; +const claudePlugins = 'https://code.claude.com/docs/en/plugins-reference (Claude Code 2.1.250 pin)'; +const codexSkills = 'https://learn.chatgpt.com/docs/build-skills (Codex 0.147.0 pin)'; +const codexPlugins = 'https://developers.openai.com/plugins/build/plugins (Codex 0.147.0 pin)'; +const cursorSkills = 'https://prod.cursor.com/docs/skills (Cursor 2026-08-28 pin)'; +const cursorPlugins = 'https://prod.cursor.com/docs/reference/plugins (cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a)'; +const portableSkills = 'https://agentskills.io/specification (69ef37e9424c0a7ea9dd2293b559e43ec8176379)'; + +const none = ( + token: SkillTokenId, + host: SkillHost, + document: SkillDocumentKind, + evidence: string, +): SkillTokenClassification => Object.freeze({ class: 'none', document, evidence, host, token }); + +const portable = ( + token: SkillTokenId, + host: SkillHost, + document: SkillDocumentKind, + syntax: string, + evidence: string, +): SkillTokenClassification => Object.freeze({ + class: 'portable', + document, + evidence, + host, + syntax, + token, +}); + +type HostDocumentTable = Partial>>>; + +const table: Record = { + claude: { + 'plugin-config': { + pluginData: portable('pluginData', 'claude', 'plugin-config', '${CLAUDE_PLUGIN_DATA}', claudePlugins), + pluginRoot: portable('pluginRoot', 'claude', 'plugin-config', '${CLAUDE_PLUGIN_ROOT}', claudePlugins), + projectRoot: portable('projectRoot', 'claude', 'plugin-config', '${CLAUDE_PROJECT_DIR}', claudePlugins), + }, + hooks: { + pluginData: portable('pluginData', 'claude', 'hooks', '${CLAUDE_PLUGIN_DATA}', claudePlugins), + pluginRoot: portable('pluginRoot', 'claude', 'hooks', '${CLAUDE_PLUGIN_ROOT}', claudePlugins), + projectRoot: portable('projectRoot', 'claude', 'hooks', '${CLAUDE_PROJECT_DIR}', claudePlugins), + }, + mcp: { + pluginData: portable('pluginData', 'claude', 'mcp', '${CLAUDE_PLUGIN_DATA}', claudePlugins), + pluginRoot: portable('pluginRoot', 'claude', 'mcp', '${CLAUDE_PLUGIN_ROOT}', claudePlugins), + projectRoot: portable('projectRoot', 'claude', 'mcp', '${CLAUDE_PROJECT_DIR}', claudePlugins), + }, + 'skill-frontmatter': { + pluginData: portable('pluginData', 'claude', 'skill-frontmatter', '${CLAUDE_PLUGIN_DATA}', claudeSkills), + pluginRoot: portable('pluginRoot', 'claude', 'skill-frontmatter', '${CLAUDE_PLUGIN_ROOT}', claudeSkills), + projectRoot: portable('projectRoot', 'claude', 'skill-frontmatter', '${CLAUDE_PROJECT_DIR}', claudeSkills), + skillRoot: portable('skillRoot', 'claude', 'skill-frontmatter', '${CLAUDE_SKILL_DIR}', claudeSkills), + }, + 'skill-markdown': { + arguments: portable('arguments', 'claude', 'skill-markdown', '$ARGUMENTS', claudeSkills), + pluginData: portable('pluginData', 'claude', 'skill-markdown', '${CLAUDE_PLUGIN_DATA}', claudeSkills), + pluginRoot: portable('pluginRoot', 'claude', 'skill-markdown', '${CLAUDE_PLUGIN_ROOT}', claudeSkills), + projectRoot: portable('projectRoot', 'claude', 'skill-markdown', '${CLAUDE_PROJECT_DIR}', claudeSkills), + sessionIdentity: portable('sessionIdentity', 'claude', 'skill-markdown', '${CLAUDE_SESSION_ID}', claudeSkills), + skillRoot: portable('skillRoot', 'claude', 'skill-markdown', '${CLAUDE_SKILL_DIR}', claudeSkills), + }, + }, + codex: { + hooks: { + pluginData: portable('pluginData', 'codex', 'hooks', '${PLUGIN_DATA}', codexPlugins), + pluginRoot: portable('pluginRoot', 'codex', 'hooks', '${PLUGIN_ROOT}', codexPlugins), + }, + mcp: { + pluginRoot: portable('pluginRoot', 'codex', 'mcp', '${PLUGIN_ROOT}', codexPlugins), + }, + 'plugin-config': { + pluginData: portable('pluginData', 'codex', 'plugin-config', '${PLUGIN_DATA}', codexPlugins), + pluginRoot: portable('pluginRoot', 'codex', 'plugin-config', '${PLUGIN_ROOT}', codexPlugins), + }, + }, + cursor: { + hooks: { + pluginRoot: portable('pluginRoot', 'cursor', 'hooks', '${CURSOR_PLUGIN_ROOT}', cursorPlugins), + projectRoot: portable('projectRoot', 'cursor', 'hooks', '${workspaceFolder}', cursorPlugins), + }, + mcp: { + pluginRoot: portable('pluginRoot', 'cursor', 'mcp', '${CURSOR_PLUGIN_ROOT}', cursorPlugins), + projectRoot: portable('projectRoot', 'cursor', 'mcp', '${workspaceFolder}', cursorPlugins), + }, + 'plugin-config': { + pluginRoot: portable('pluginRoot', 'cursor', 'plugin-config', '${CURSOR_PLUGIN_ROOT}', cursorPlugins), + projectRoot: portable('projectRoot', 'cursor', 'plugin-config', '${workspaceFolder}', cursorPlugins), + }, + }, + portable: { + mcp: { + pluginData: portable('pluginData', 'portable', 'mcp', '${PLUGIN_DATA}', portableSkills), + pluginRoot: portable('pluginRoot', 'portable', 'mcp', '${PLUGIN_ROOT}', portableSkills), + }, + 'plugin-config': { + pluginData: portable('pluginData', 'portable', 'plugin-config', '${PLUGIN_DATA}', portableSkills), + pluginRoot: portable('pluginRoot', 'portable', 'plugin-config', '${PLUGIN_ROOT}', portableSkills), + }, + }, +}; + +const noSkillMarkdown = { + claude: claudeSkills, + codex: `${codexSkills}: Codex documents no Skill Markdown interpolation engine`, + cursor: `${cursorSkills}: documented \${VAR} interpolation belongs to plugin configuration, not Skill Markdown`, + portable: `${portableSkills}: portable Agent Skills define no runtime placeholder syntax`, +} as const; + +export const classifySkillToken = ( + token: SkillTokenId, + host: SkillHost, + document: SkillDocumentKind, +): SkillTokenClassification => + table[host][document]?.[token] ?? none(token, host, document, noSkillMarkdown[host]); + +/** Host-native spellings that parse as a canonical token. Longest match wins. */ +export const skillTokenAliases: Readonly> = Object.freeze({ + arguments: Object.freeze(['$ARGUMENTS']), + pluginData: Object.freeze(['${CLAUDE_PLUGIN_DATA}', '${PLUGIN_DATA}']), + pluginRoot: Object.freeze(['${CLAUDE_PLUGIN_ROOT}', '${CURSOR_PLUGIN_ROOT}', '${PLUGIN_ROOT}']), + projectRoot: Object.freeze(['${CLAUDE_PROJECT_DIR}', '${workspaceFolder}']), + sessionIdentity: Object.freeze(['${CLAUDE_SESSION_ID}']), + skillRoot: Object.freeze(['${CLAUDE_SKILL_DIR}']), +}); + +const aliasEntries = (Object.entries(skillTokenAliases) as [SkillTokenId, readonly string[]][]) + .flatMap(([token, aliases]) => [ + { alias: skillTokenSpellings[token], token }, + ...aliases.map((alias) => ({ alias, token })), + ]) + .sort((left, right) => right.alias.length - left.alias.length); + +export interface SkillTokenOccurrence { + readonly alias: string; + readonly index: number; + readonly token: SkillTokenId; +} + +/** Finds canonical spellings and host-native aliases. Does not resolve runtime values. */ +export const findSkillTokens = (text: string): readonly SkillTokenOccurrence[] => { + const found: SkillTokenOccurrence[] = []; + const consumed = new Set(); + for (const { alias, token } of aliasEntries) { + let from = 0; + while (from <= text.length - alias.length) { + const index = text.indexOf(alias, from); + if (index === -1) break; + let already = false; + for (let offset = 0; offset < alias.length; offset += 1) { + if (consumed.has(index + offset)) { + already = true; + break; + } + } + if (!already) { + found.push({ alias, index, token }); + for (let offset = 0; offset < alias.length; offset += 1) consumed.add(index + offset); + } + from = index + alias.length; + } + } + return Object.freeze(found.sort((left, right) => left.index - right.index || left.token.localeCompare(right.token))); +}; + +const hostSkillMarkdownSyntax = (host: SkillHost): readonly string[] => + (Object.keys(skillTokenSpellings) as SkillTokenId[]) + .map((token) => classifySkillToken(token, host, 'skill-markdown').syntax) + .filter((syntax): syntax is string => syntax !== undefined); + +/** Syntax that belongs to a different host's Skill Markdown and must not leak. */ +export const foreignSkillMarkdownSyntax = (host: SkillHost): readonly string[] => { + const owned = new Set(hostSkillMarkdownSyntax(host)); + const foreign = new Set(); + for (const other of ['claude', 'codex', 'cursor', 'portable'] as const) { + if (other === host) continue; + for (const syntax of hostSkillMarkdownSyntax(other)) { + if (!owned.has(syntax)) foreign.add(syntax); + } + } + return Object.freeze([...foreign].sort()); +}; + +export const replaceSkillTokens = ( + text: string, + replace: (occurrence: SkillTokenOccurrence) => string, +): string => { + const occurrences = findSkillTokens(text); + if (occurrences.length === 0) return text; + let result = ''; + let cursor = 0; + for (const occurrence of occurrences) { + result += text.slice(cursor, occurrence.index); + result += replace(occurrence); + cursor = occurrence.index + occurrence.alias.length; + } + return result + text.slice(cursor); +}; diff --git a/packages/agent-bundle/tests/normalization.test.ts b/packages/agent-bundle/tests/normalization.test.ts index ba61344d7..da65cac60 100644 --- a/packages/agent-bundle/tests/normalization.test.ts +++ b/packages/agent-bundle/tests/normalization.test.ts @@ -436,8 +436,9 @@ it('maps pinned Agent Skills schema issues to stable source diagnostics without registry, )).toEqual([ { - code: 'AB4007', - message: 'Skill frontmatter unknown must NOT have additional properties.', + code: 'AB3006', + message: 'Skill frontmatter field "unknown" is not a portable Agent Skills field or a typed host extension.', + recovery: 'Move host-only fields into `targets.` or a documented host key, or remove the unknown field.', severity: 'error', sourcePath: document.source, }, diff --git a/packages/agent-bundle/tests/skill-ir.test.ts b/packages/agent-bundle/tests/skill-ir.test.ts new file mode 100644 index 000000000..70ef88b8b --- /dev/null +++ b/packages/agent-bundle/tests/skill-ir.test.ts @@ -0,0 +1,399 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { readFile } from 'node:fs/promises'; + +import { afterEach, describe, expect, it } from '@rstest/core'; + +import { sha256Hex } from '../src/core/digest.ts'; +import { skillHostSchemaRevision } from '../src/schemas/skill-hosts/contract.ts'; + +import { standardPluginArtifactPlan } from '../src/adapters/types.ts'; +import { + discoverProject, + normalizeProject, + parseSkill, + type NormalizationTargetRegistry, +} from '../src/config/index.ts'; +import type { LoadedConfig } from '../src/config/load.ts'; +import type { AgentBundleConfig } from '../src/core/types.ts'; +import { defineSkill, Skill } from '../src/skills/define.ts'; +import { inspectSkillProjection } from '../src/skills/inspect.ts'; +import { lowerSkillIr } from '../src/skills/lower.ts'; +import { parseSkillIr } from '../src/skills/parse-ir.ts'; +import { pathTokens } from '../src/core/types.ts'; +import { + classifySkillToken, + skillTokenSpellings, + type SkillHost, +} from '../src/skills/tokens.ts'; + +const portableMarkdown = [ + '---', + 'name: review', + 'description: Identify the purpose of a small repository fixture.', + '---', + '', + '# Review', + '', + 'Read the repository README and briefly state its purpose.', + '', +].join('\n'); + +const registry: NormalizationTargetRegistry = { + configExtensions: () => [], + defaultTargetNames: () => ['claude', 'codex', 'cursor'], + has: (name) => ['portable', 'codex', 'claude', 'cursor', 'plugin'].includes(name), + supports: () => true, +}; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { force: true, recursive: true }))); +}); + +const projectRoot = async (files: Readonly>): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-skill-ir-'))); + roots.push(root); + for (const [path, contents] of Object.entries(files)) { + const destination = join(root, path); + await mkdir(join(destination, '..'), { recursive: true }); + await writeFile(destination, contents); + } + return root; +}; + +const loadedProject = (config: AgentBundleConfig, root: string): LoadedConfig => ({ + config, + configPath: `${root}/agent-bundle.config.ts`, + context: { + command: 'build', + mode: 'production', + projectRoot: root, + selectedTargets: [], + }, +}); + +const pluginConfig = (targets: readonly string[]): AgentBundleConfig => ({ + plugin: { name: 'skill-ir', version: '0.0.0' }, + targets: [...targets], +}); + +describe('skill host schema pins', () => { + it('pins closed per-host Skill schemas to immutable provenance', async () => { + const provenance = JSON.parse(await readFile( + new URL('../src/schemas/skill-hosts/PROVENANCE.json', import.meta.url), + 'utf8', + )) as { + readonly derivedSchemas: Readonly>; + readonly retrievedAt: string; + }; + expect(skillHostSchemaRevision.retrievedAt).toBe(provenance.retrievedAt); + for (const [name, expected] of Object.entries(provenance.derivedSchemas)) { + const bytes = await readFile(new URL(`../src/schemas/skill-hosts/${name}`, import.meta.url)); + expect(bytes.byteLength, name).toBe(expected.bytes); + expect(sha256Hex(bytes), name).toBe(expected.sha256); + } + }); +}); + +describe('skill token registry', () => { + it('shares plugin and project root spellings with pathTokens', () => { + expect(skillTokenSpellings.pluginRoot).toBe(pathTokens.pluginRoot); + expect(skillTokenSpellings.pluginData).toBe(pathTokens.pluginData); + expect(skillTokenSpellings.projectRoot).toBe(pathTokens.workspaceRoot); + }); + + it('classifies the six canonical tokens per host in Skill Markdown', () => { + const hosts = ['claude', 'codex', 'cursor', 'portable'] as const satisfies readonly SkillHost[]; + const expected: Record> = { + claude: { + arguments: 'portable', + pluginData: 'portable', + pluginRoot: 'portable', + projectRoot: 'portable', + sessionIdentity: 'portable', + skillRoot: 'portable', + }, + codex: { + arguments: 'none', + pluginData: 'none', + pluginRoot: 'none', + projectRoot: 'none', + sessionIdentity: 'none', + skillRoot: 'none', + }, + cursor: { + arguments: 'none', + pluginData: 'none', + pluginRoot: 'none', + projectRoot: 'none', + sessionIdentity: 'none', + skillRoot: 'none', + }, + portable: { + arguments: 'none', + pluginData: 'none', + pluginRoot: 'none', + projectRoot: 'none', + sessionIdentity: 'none', + skillRoot: 'none', + }, + }; + + for (const host of hosts) { + for (const token of Object.keys(skillTokenSpellings) as (keyof typeof skillTokenSpellings)[]) { + expect(classifySkillToken(token, host, 'skill-markdown').class, `${host}/${token}`).toBe( + expected[host][token], + ); + } + } + }); + + it('lowers plugin-surface tokens to host syntax in the documented document, never at build time', () => { + expect(classifySkillToken('pluginRoot', 'claude', 'plugin-config').syntax).toBe('${CLAUDE_PLUGIN_ROOT}'); + expect(classifySkillToken('pluginData', 'claude', 'plugin-config').syntax).toBe('${CLAUDE_PLUGIN_DATA}'); + expect(classifySkillToken('projectRoot', 'claude', 'plugin-config').syntax).toBe('${CLAUDE_PROJECT_DIR}'); + expect(classifySkillToken('pluginRoot', 'codex', 'hooks').syntax).toBe('${PLUGIN_ROOT}'); + expect(classifySkillToken('pluginData', 'codex', 'hooks').syntax).toBe('${PLUGIN_DATA}'); + expect(classifySkillToken('pluginRoot', 'cursor', 'plugin-config').syntax).toBe('${CURSOR_PLUGIN_ROOT}'); + expect(classifySkillToken('projectRoot', 'cursor', 'plugin-config').syntax).toBe('${workspaceFolder}'); + expect(classifySkillToken('pluginData', 'cursor', 'plugin-config').class).toBe('none'); + expect(classifySkillToken('arguments', 'claude', 'skill-markdown').syntax).toBe('$ARGUMENTS'); + expect(classifySkillToken('skillRoot', 'claude', 'skill-markdown').syntax).toBe('${CLAUDE_SKILL_DIR}'); + expect(classifySkillToken('sessionIdentity', 'claude', 'skill-markdown').syntax).toBe('${CLAUDE_SESSION_ID}'); + }); +}); + +describe('canonical Skill IR', () => { + it('keeps a portable SKILL.md byte-stable when no extension or placeholder requires target output', async () => { + const root = await projectRoot({ 'skills/review/SKILL.md': portableMarkdown }); + const document = await parseSkill(join(root, 'skills', 'review'), root); + const ir = parseSkillIr(document); + expect(ir.diagnostics).toEqual([]); + expect(ir.passThrough).toBe(true); + expect(ir.markdown).toBe(portableMarkdown); + + for (const host of ['claude', 'codex', 'cursor', 'portable'] as const) { + const lowered = lowerSkillIr(ir, host); + expect(lowered.diagnostics.filter((diagnostic) => diagnostic.severity === 'error')).toEqual([]); + expect(lowered.skillMarkdown).toBe(portableMarkdown); + expect(lowered.passThrough).toBe(true); + } + }); + + it('peels typed host extensions and never copies them into another host document', async () => { + const markdown = [ + '---', + 'name: review', + 'description: Review a change and report actionable findings.', + 'model: sonnet', + 'context: fork', + 'paths:', + ' - src/**', + 'disable-model-invocation: true', + 'targets:', + ' codex:', + ' interface:', + ' display_name: Review change', + ' policy:', + ' allow_implicit_invocation: true', + '---', + '', + '# Review', + '', + 'Review the change.', + '', + ].join('\n'); + const root = await projectRoot({ 'skills/review/SKILL.md': markdown }); + const document = await parseSkill(join(root, 'skills', 'review'), root); + const ir = parseSkillIr(document); + expect(ir.passThrough).toBe(false); + expect(ir.extensions.claude).toEqual(expect.objectContaining({ context: 'fork', model: 'sonnet' })); + expect(ir.extensions.cursor).toEqual(expect.objectContaining({ + disableModelInvocation: true, + paths: ['src/**'], + })); + expect(ir.extensions.codex).toEqual(expect.objectContaining({ + interface: { displayName: 'Review change' }, + policy: { allowImplicitInvocation: true }, + })); + + const claude = lowerSkillIr(ir, 'claude'); + expect(claude.frontmatter.model).toBe('sonnet'); + expect(claude.frontmatter.context).toBe('fork'); + expect(claude.frontmatter).not.toHaveProperty('display_name'); + expect(claude.sidecars).toEqual([]); + + const cursor = lowerSkillIr(ir, 'cursor'); + expect(cursor.frontmatter.paths).toEqual(['src/**']); + expect(cursor.frontmatter['disable-model-invocation']).toBe(true); + expect(cursor.frontmatter).not.toHaveProperty('model'); + expect(cursor.frontmatter).not.toHaveProperty('context'); + + const codex = lowerSkillIr(ir, 'codex'); + expect(codex.frontmatter).not.toHaveProperty('model'); + expect(codex.frontmatter).not.toHaveProperty('paths'); + expect(codex.sidecars).toEqual([expect.objectContaining({ + relativePath: 'agents/openai.yaml', + })]); + expect(codex.sidecars[0]?.content).toContain('display_name: Review change'); + expect(codex.sidecars[0]?.content).toContain('allow_implicit_invocation: true'); + }); + + it('diagnoses a required token that the selected host cannot express', async () => { + const markdown = [ + '---', + 'name: review', + 'description: Review arguments in the project.', + '---', + '', + `Review ${skillTokenSpellings.arguments} in ${skillTokenSpellings.projectRoot}.`, + '', + ].join('\n'); + const root = await projectRoot({ 'skills/review/SKILL.md': markdown }); + const ir = parseSkillIr(await parseSkill(join(root, 'skills', 'review'), root)); + expect(ir.passThrough).toBe(false); + expect(ir.placeholders.map((placeholder) => placeholder.token)).toEqual(['arguments', 'projectRoot']); + + const claude = lowerSkillIr(ir, 'claude'); + expect(claude.diagnostics).toEqual([]); + expect(claude.skillMarkdown).toContain('$ARGUMENTS'); + expect(claude.skillMarkdown).toContain('${CLAUDE_PROJECT_DIR}'); + expect(claude.skillMarkdown).not.toContain(skillTokenSpellings.arguments); + expect(claude.skillMarkdown).not.toContain('${CURSOR_PLUGIN_ROOT}'); + + const cursor = lowerSkillIr(ir, 'cursor'); + expect(cursor.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'AB3008', severity: 'error', target: 'cursor' }), + ])); + expect(cursor.skillMarkdown).not.toContain('$ARGUMENTS'); + expect(cursor.skillMarkdown).not.toContain(skillTokenSpellings.arguments); + }); + + it('rejects unknown source fields instead of smuggling them through a closed schema', async () => { + const markdown = [ + '---', + 'name: review', + 'description: Review a change.', + 'invented-host-field: true', + '---', + '', + '# Review', + '', + ].join('\n'); + const root = await projectRoot({ 'skills/review/SKILL.md': markdown }); + const ir = parseSkillIr(await parseSkill(join(root, 'skills', 'review'), root)); + expect(ir.diagnostics).toEqual([expect.objectContaining({ + code: 'AB3006', + message: expect.stringContaining('invented-host-field'), + severity: 'error', + })]); + }); + + it('surfaces the shared-vs-per-host skills tree as an inspect-visible evidence decision', async () => { + const root = await projectRoot({ + 'skills/review/SKILL.md': [ + '---', + 'name: review', + 'description: Review a change.', + 'model: sonnet', + '---', + '', + '# Review', + '', + ].join('\n'), + }); + const ir = parseSkillIr(await parseSkill(join(root, 'skills', 'review'), root)); + const inspection = inspectSkillProjection(ir, ['claude', 'codex', 'cursor']); + expect(inspection.authoredMarkdown).toContain('model: sonnet'); + expect(inspection.skillTreeLayout.decision).toBe('per-host-required'); + expect(inspection.skillTreeLayout.feeds).toBe('#101'); + expect(inspection.hostDocuments.claude?.frontmatter).toEqual(expect.objectContaining({ model: 'sonnet' })); + expect(inspection.hostDocuments.codex?.frontmatter).not.toHaveProperty('model'); + expect(inspection.tokenLowering.length).toBeGreaterThanOrEqual(0); + }); +}); + +describe('static lowering through the rendered-skill path', () => { + it('keeps defineSkill and Skill token components as identity helpers over the registry', () => { + const skill = defineSkill({ + name: 'review', + description: 'Review a change and report actionable findings.', + targets: { + claude: { model: 'sonnet', context: 'fork' }, + cursor: { paths: ['src/**'] }, + }, + }); + expect(skill.targets.claude).toEqual({ context: 'fork', model: 'sonnet' }); + expect(Skill.Arguments()).toBe(skillTokenSpellings.arguments); + expect(Skill.ProjectRoot()).toBe(skillTokenSpellings.projectRoot); + expect(Skill.Resource({ path: 'references/checklist.md' })).toBe( + '[references/checklist.md](references/checklist.md)', + ); + }); + + it('compiles SKILL.tsx at build time and projects per-host Markdown without a Flight client', async () => { + const root = await projectRoot({ + 'skills/review/SKILL.ts': [ + `const argumentsToken = ${JSON.stringify(skillTokenSpellings.arguments)};`, + `const projectRootToken = ${JSON.stringify(skillTokenSpellings.projectRoot)};`, + "export const frontmatter = { description: 'Review a change and report actionable findings.', name: 'review' };", + 'export const targets = {', + " claude: { model: 'sonnet', context: 'fork' },", + " cursor: { paths: ['src/**'] },", + " codex: { interface: { displayName: 'Review change' }, policy: { allowImplicitInvocation: true } },", + '};', + 'export default function ReviewSkill() {', + " return [{ props: { children: 'Review the change' }, type: 'h1' }, { props: { children: ['Review ', argumentsToken, ' in ', projectRootToken, '.'] }, type: 'p' }];", + '}', + '', + ].join('\n'), + }); + const document = await parseSkill(join(root, 'skills', 'review'), root); + expect(document.diagnostics).toEqual([]); + expect(document.rendered).toBe(true); + const ir = parseSkillIr(document); + expect(ir.extensions.claude).toEqual({ context: 'fork', model: 'sonnet' }); + const claude = lowerSkillIr(ir, 'claude'); + expect(claude.skillMarkdown).toContain('$ARGUMENTS'); + expect(claude.skillMarkdown).toContain('${CLAUDE_PROJECT_DIR}'); + expect(claude.frontmatter.model).toBe('sonnet'); + }); + + it('lets the artifact planner own destinations and keeps portable skills as copy pass-through', async () => { + const root = await projectRoot({ + 'agent-bundle.config.ts': '', + 'skills/review/SKILL.md': portableMarkdown, + }); + const loaded = loadedProject(pluginConfig(['claude', 'codex', 'cursor']), root); + const discovered = await discoverProject(root, loaded.config); + const model = await normalizeProject(loaded, discovered, registry); + const skill = model.skills[0]; + expect(skill?.skillIr?.passThrough).toBe(true); + expect(skill?.hostDocuments?.claude?.passThrough).toBe(true); + expect(skill?.skillTreeLayout?.decision).toBe('shared'); + + const plan = standardPluginArtifactPlan({ + diagnostics: [], + hookDocumentValid: false, + hookEntries: [], + hookManifestPath: 'hooks/hooks.json', + isSelected: () => true, + marketplaceRelativePath: '.claude-plugin/marketplace.json', + marketplaceValid: false, + mcpValid: false, + model, + plugin: { name: 'skill-ir' }, + pluginRelativePath: '.claude-plugin/plugin.json', + targetName: 'claude', + }); + const skillMd = plan.entries.find((entry) => entry.relativePath === 'skills/review/SKILL.md'); + expect(skillMd).toEqual(expect.objectContaining({ + kind: 'copy', + source: skill?.source, + })); + }); +});