verify-skill-invocation-metadata.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. /**
  2. * Keep Claude Code and Codex invocation metadata aligned for repository skills.
  3. * @module scripts/verify-skill-invocation-metadata
  4. */
  5. import { existsSync, readFileSync, readdirSync } from 'node:fs'
  6. import { resolve } from 'node:path'
  7. import { load } from 'js-yaml'
  8. const ROOT = resolve(import.meta.dirname, '..')
  9. /** Return an object-shaped YAML value, or undefined for every other shape. */
  10. function asRecord(value: unknown): Record<string, unknown> | undefined {
  11. return typeof value === 'object' && value !== null && !Array.isArray(value)
  12. ? value as Record<string, unknown>
  13. : undefined
  14. }
  15. /** Parse a skill's YAML frontmatter as an object. */
  16. function parseSkillFrontmatter(source: string): Record<string, unknown> {
  17. const lines = source.split('\n')
  18. if (lines[0] !== '---') throw new Error('SKILL.md must start with YAML frontmatter')
  19. const end = lines.indexOf('---', 1)
  20. if (end < 0) throw new Error('SKILL.md frontmatter is not closed')
  21. const metadata = asRecord(load(lines.slice(1, end).join('\n')))
  22. if (metadata === undefined) throw new Error('SKILL.md frontmatter must be a YAML object')
  23. return metadata
  24. }
  25. /** Find repository skill directories that carry Codex product metadata. */
  26. function skillDirectories(root: string): string[] {
  27. const skillsRoot = resolve(root, '.agents/skills')
  28. if (!existsSync(skillsRoot)) return []
  29. return readdirSync(skillsRoot, { withFileTypes: true })
  30. .filter(entry => entry.isDirectory() && existsSync(resolve(skillsRoot, entry.name, 'agents/openai.yaml')))
  31. .map(entry => entry.name)
  32. .sort()
  33. }
  34. /**
  35. * Report cross-product invocation-policy mismatches for repository skills.
  36. * @param root - Repository root containing `.agents/skills`.
  37. * @returns diagnostics for malformed metadata or policies that expose a skill differently.
  38. */
  39. export function collectSkillInvocationMetadataViolations(root: string): string[] {
  40. const violations: string[] = []
  41. for (const skill of skillDirectories(root)) {
  42. const relativeRoot = `.agents/skills/${skill}`
  43. const skillFile = resolve(root, relativeRoot, 'SKILL.md')
  44. const openaiFile = resolve(root, relativeRoot, 'agents/openai.yaml')
  45. if (!existsSync(skillFile)) {
  46. violations.push(`${relativeRoot}: agents/openai.yaml has no sibling SKILL.md`)
  47. continue
  48. }
  49. let frontmatter: Record<string, unknown>
  50. let openai: Record<string, unknown>
  51. try {
  52. frontmatter = parseSkillFrontmatter(readFileSync(skillFile, 'utf8'))
  53. }
  54. catch (error) {
  55. violations.push(`${relativeRoot}/SKILL.md: ${error instanceof Error ? error.message : String(error)}`)
  56. continue
  57. }
  58. try {
  59. const parsed = asRecord(load(readFileSync(openaiFile, 'utf8')))
  60. if (parsed === undefined) throw new Error('agents/openai.yaml must be a YAML object')
  61. openai = parsed
  62. }
  63. catch (error) {
  64. violations.push(`${relativeRoot}/agents/openai.yaml: ${error instanceof Error ? error.message : String(error)}`)
  65. continue
  66. }
  67. const disableModelInvocation = frontmatter['disable-model-invocation']
  68. if (disableModelInvocation !== undefined && typeof disableModelInvocation !== 'boolean') {
  69. violations.push(`${relativeRoot}/SKILL.md: disable-model-invocation must be a boolean`)
  70. continue
  71. }
  72. const userInvocable = frontmatter['user-invocable']
  73. if (userInvocable !== undefined && typeof userInvocable !== 'boolean') {
  74. violations.push(`${relativeRoot}/SKILL.md: user-invocable must be a boolean`)
  75. continue
  76. }
  77. const policy = asRecord(openai.policy)
  78. const allowImplicitInvocation = policy?.allow_implicit_invocation
  79. if (allowImplicitInvocation !== undefined && typeof allowImplicitInvocation !== 'boolean') {
  80. violations.push(`${relativeRoot}/agents/openai.yaml: policy.allow_implicit_invocation must be a boolean`)
  81. continue
  82. }
  83. const claudeManualOnly = disableModelInvocation === true
  84. const codexManualOnly = allowImplicitInvocation === false
  85. if (claudeManualOnly !== codexManualOnly) {
  86. violations.push(
  87. `${relativeRoot}: Claude Code manual-only=${String(claudeManualOnly)}`
  88. + ` but Codex manual-only=${String(codexManualOnly)}`,
  89. )
  90. }
  91. if (claudeManualOnly && userInvocable === false) {
  92. violations.push(`${relativeRoot}/SKILL.md: a manual-only skill must remain user-invocable`)
  93. }
  94. }
  95. return violations
  96. }
  97. if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
  98. const skills = skillDirectories(ROOT)
  99. const violations = collectSkillInvocationMetadataViolations(ROOT)
  100. if (violations.length > 0) {
  101. process.stderr.write('verify-skill-invocation-metadata: violations found:\n')
  102. for (const violation of violations) process.stderr.write(` ${violation}\n`)
  103. process.exit(1)
  104. }
  105. process.stdout.write(
  106. `verify-skill-invocation-metadata: ${String(skills.length)} cross-product skill policy pair(s) aligned.\n`,
  107. )
  108. }