frontmatterParser.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. /**
  2. * Frontmatter parser for markdown files
  3. * Extracts and parses YAML frontmatter between --- delimiters
  4. */
  5. import { logForDebugging } from './debug.js'
  6. import type { HooksSettings } from './settings/types.js'
  7. import { parseYaml } from './yaml.js'
  8. export type FrontmatterData = {
  9. // YAML can return null for keys with no value (e.g., "key:" with nothing after)
  10. 'allowed-tools'?: string | string[] | null
  11. description?: string | null
  12. // Memory type: 'user', 'feedback', 'project', or 'reference'
  13. // Only applicable to memory files; narrowed via parseMemoryType() in src/memdir/memoryTypes.ts
  14. type?: string | null
  15. 'argument-hint'?: string | null
  16. when_to_use?: string | null
  17. version?: string | null
  18. // Only applicable to slash commands -- a string similar to a boolean env var
  19. // to determine whether to make them visible to the SlashCommand tool.
  20. 'hide-from-slash-command-tool'?: string | null
  21. // Model alias or name (e.g., 'haiku', 'sonnet', 'opus', or specific model names)
  22. // Use 'inherit' for commands to use the parent model
  23. model?: string | null
  24. // Comma-separated list of skill names to preload (only applicable to agents)
  25. skills?: string | null
  26. // Whether users can invoke this skill by typing /skill-name
  27. // 'true' = user can type /skill-name to invoke
  28. // 'false' = only model can invoke via Skill tool
  29. // Default depends on source: commands/ defaults to true, skills/ defaults to false
  30. 'user-invocable'?: string | null
  31. // Hooks to register when this skill is invoked
  32. // Keys are hook events (PreToolUse, PostToolUse, Stop, etc.)
  33. // Values are arrays of matcher configurations with hooks
  34. // Validated by HooksSchema in loadSkillsDir.ts
  35. hooks?: HooksSettings | null
  36. // Effort level for agents (e.g., 'low', 'medium', 'high', 'max', or an integer)
  37. // Controls the thinking effort used by the agent's model
  38. effort?: string | null
  39. // Execution context for skills: 'inline' (default) or 'fork' (run as sub-agent)
  40. // 'inline' = skill content expands into the current conversation
  41. // 'fork' = skill runs in a sub-agent with separate context and token budget
  42. context?: 'inline' | 'fork' | null
  43. // Agent type to use when forked (e.g., 'Bash', 'general-purpose')
  44. // Only applicable when context is 'fork'
  45. agent?: string | null
  46. // Glob patterns for file paths this skill applies to. Accepts either a
  47. // comma-separated string or a YAML list of strings.
  48. // When set, the skill is only activated when the model touches matching files
  49. // Uses the same format as CLAUDE.md paths frontmatter
  50. paths?: string | string[] | null
  51. // Shell to use for !`cmd` and ```! blocks in skill/command .md content.
  52. // 'bash' (default) or 'powershell'. File-scoped — applies to all !-blocks.
  53. // Never consults settings.defaultShell: skills are portable across platforms,
  54. // so the author picks the shell, not the reader. See docs/design/ps-shell-selection.md §5.3.
  55. shell?: string | null
  56. [key: string]: unknown
  57. }
  58. export type ParsedMarkdown = {
  59. frontmatter: FrontmatterData
  60. content: string
  61. }
  62. // Characters that require quoting in YAML values (when unquoted)
  63. // - { } are flow mapping indicators
  64. // - * is anchor/alias indicator
  65. // - [ ] are flow sequence indicators
  66. // - ': ' (colon followed by space) is key indicator — causes 'Nested mappings
  67. // are not allowed in compact mappings' when it appears mid-value. Match the
  68. // pattern rather than bare ':' so '12:34' times and 'https://' URLs stay unquoted.
  69. // - # is comment indicator
  70. // - & is anchor indicator
  71. // - ! is tag indicator
  72. // - | > are block scalar indicators (only at start)
  73. // - % is directive indicator (only at start)
  74. // - @ ` are reserved
  75. const YAML_SPECIAL_CHARS = /[{}[\]*&#!|>%@`]|: /
  76. /**
  77. * Pre-processes frontmatter text to quote values that contain special YAML characters.
  78. * This allows glob patterns like **\/*.{ts,tsx} to be parsed correctly.
  79. */
  80. function quoteProblematicValues(frontmatterText: string): string {
  81. const lines = frontmatterText.split('\n')
  82. const result: string[] = []
  83. for (const line of lines) {
  84. // Match simple key: value lines (not indented, not list items, not block scalars)
  85. const match = line.match(/^([a-zA-Z_-]+):\s+(.+)$/)
  86. if (match) {
  87. const [, key, value] = match
  88. if (!key || !value) {
  89. result.push(line)
  90. continue
  91. }
  92. // Skip if already quoted
  93. if (
  94. (value.startsWith('"') && value.endsWith('"')) ||
  95. (value.startsWith("'") && value.endsWith("'"))
  96. ) {
  97. result.push(line)
  98. continue
  99. }
  100. // Quote if contains special YAML characters
  101. if (YAML_SPECIAL_CHARS.test(value)) {
  102. // Use double quotes and escape any existing double quotes
  103. const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
  104. result.push(`${key}: "${escaped}"`)
  105. continue
  106. }
  107. }
  108. result.push(line)
  109. }
  110. return result.join('\n')
  111. }
  112. export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/
  113. /**
  114. * Parses markdown content to extract frontmatter and content
  115. * @param markdown The raw markdown content
  116. * @returns Object containing parsed frontmatter and content without frontmatter
  117. */
  118. export function parseFrontmatter(
  119. markdown: string,
  120. sourcePath?: string,
  121. ): ParsedMarkdown {
  122. const match = markdown.match(FRONTMATTER_REGEX)
  123. if (!match) {
  124. // No frontmatter found
  125. return {
  126. frontmatter: {},
  127. content: markdown,
  128. }
  129. }
  130. const frontmatterText = match[1] || ''
  131. const content = markdown.slice(match[0].length)
  132. let frontmatter: FrontmatterData = {}
  133. try {
  134. const parsed = parseYaml(frontmatterText) as FrontmatterData | null
  135. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
  136. frontmatter = parsed
  137. }
  138. } catch {
  139. // YAML parsing failed - try again after quoting problematic values
  140. try {
  141. const quotedText = quoteProblematicValues(frontmatterText)
  142. const parsed = parseYaml(quotedText) as FrontmatterData | null
  143. if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
  144. frontmatter = parsed
  145. }
  146. } catch (retryError) {
  147. // Still failed - log for debugging so users can diagnose broken frontmatter
  148. const location = sourcePath ? ` in ${sourcePath}` : ''
  149. logForDebugging(
  150. `Failed to parse YAML frontmatter${location}: ${retryError instanceof Error ? retryError.message : retryError}`,
  151. { level: 'warn' },
  152. )
  153. }
  154. }
  155. return {
  156. frontmatter,
  157. content,
  158. }
  159. }
  160. /**
  161. * Splits a comma-separated string and expands brace patterns.
  162. * Commas inside braces are not treated as separators.
  163. * Also accepts a YAML list (string array) for ergonomic frontmatter.
  164. * @param input - Comma-separated string, or array of strings, with optional brace patterns
  165. * @returns Array of expanded strings
  166. * @example
  167. * splitPathInFrontmatter("a, b") // returns ["a", "b"]
  168. * splitPathInFrontmatter("a, src/*.{ts,tsx}") // returns ["a", "src/*.ts", "src/*.tsx"]
  169. * splitPathInFrontmatter("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"]
  170. * splitPathInFrontmatter(["a", "src/*.{ts,tsx}"]) // returns ["a", "src/*.ts", "src/*.tsx"]
  171. */
  172. export function splitPathInFrontmatter(input: string | string[]): string[] {
  173. if (Array.isArray(input)) {
  174. return input.flatMap(splitPathInFrontmatter)
  175. }
  176. if (typeof input !== 'string') {
  177. return []
  178. }
  179. // Split by comma while respecting braces
  180. const parts: string[] = []
  181. let current = ''
  182. let braceDepth = 0
  183. for (let i = 0; i < input.length; i++) {
  184. const char = input[i]
  185. if (char === '{') {
  186. braceDepth++
  187. current += char
  188. } else if (char === '}') {
  189. braceDepth--
  190. current += char
  191. } else if (char === ',' && braceDepth === 0) {
  192. // Split here - we're at a comma outside of braces
  193. const trimmed = current.trim()
  194. if (trimmed) {
  195. parts.push(trimmed)
  196. }
  197. current = ''
  198. } else {
  199. current += char
  200. }
  201. }
  202. // Add the last part
  203. const trimmed = current.trim()
  204. if (trimmed) {
  205. parts.push(trimmed)
  206. }
  207. // Expand brace patterns in each part
  208. return parts
  209. .filter(p => p.length > 0)
  210. .flatMap(pattern => expandBraces(pattern))
  211. }
  212. /**
  213. * Expands brace patterns in a glob string.
  214. * @example
  215. * expandBraces("src/*.{ts,tsx}") // returns ["src/*.ts", "src/*.tsx"]
  216. * expandBraces("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"]
  217. */
  218. function expandBraces(pattern: string): string[] {
  219. // Find the first brace group
  220. const braceMatch = pattern.match(/^([^{]*)\{([^}]+)\}(.*)$/)
  221. if (!braceMatch) {
  222. // No braces found, return pattern as-is
  223. return [pattern]
  224. }
  225. const prefix = braceMatch[1] || ''
  226. const alternatives = braceMatch[2] || ''
  227. const suffix = braceMatch[3] || ''
  228. // Split alternatives by comma and expand each one
  229. const parts = alternatives.split(',').map(alt => alt.trim())
  230. // Recursively expand remaining braces in suffix
  231. const expanded: string[] = []
  232. for (const part of parts) {
  233. const combined = prefix + part + suffix
  234. // Recursively handle additional brace groups
  235. const furtherExpanded = expandBraces(combined)
  236. expanded.push(...furtherExpanded)
  237. }
  238. return expanded
  239. }
  240. /**
  241. * Parses a positive integer value from frontmatter.
  242. * Handles both number and string representations.
  243. *
  244. * @param value The raw value from frontmatter (could be number, string, or undefined)
  245. * @returns The parsed positive integer, or undefined if invalid or not provided
  246. */
  247. export function parsePositiveIntFromFrontmatter(
  248. value: unknown,
  249. ): number | undefined {
  250. if (value === undefined || value === null) {
  251. return undefined
  252. }
  253. const parsed = typeof value === 'number' ? value : parseInt(String(value), 10)
  254. if (Number.isInteger(parsed) && parsed > 0) {
  255. return parsed
  256. }
  257. return undefined
  258. }
  259. /**
  260. * Validate and coerce a description value from frontmatter.
  261. *
  262. * Strings are returned as-is (trimmed). Primitive values (numbers, booleans)
  263. * are coerced to strings via String(). Non-scalar values (arrays, objects)
  264. * are invalid and are logged then omitted. Null, undefined, and
  265. * empty/whitespace-only strings return null so callers can fall back to
  266. * a default.
  267. *
  268. * @param value - The raw frontmatter description value
  269. * @param componentName - The skill/command/agent/style name for log messages
  270. * @param pluginName - The plugin name, if this came from a plugin
  271. */
  272. export function coerceDescriptionToString(
  273. value: unknown,
  274. componentName?: string,
  275. pluginName?: string,
  276. ): string | null {
  277. if (value == null) {
  278. return null
  279. }
  280. if (typeof value === 'string') {
  281. return value.trim() || null
  282. }
  283. if (typeof value === 'number' || typeof value === 'boolean') {
  284. return String(value)
  285. }
  286. // Non-scalar descriptions (arrays, objects) are invalid — log and omit
  287. const source = pluginName
  288. ? `${pluginName}:${componentName}`
  289. : (componentName ?? 'unknown')
  290. logForDebugging(`Description invalid for ${source} - omitting`, {
  291. level: 'warn',
  292. })
  293. return null
  294. }
  295. /**
  296. * Parse a boolean frontmatter value.
  297. * Only returns true for literal true or "true" string.
  298. */
  299. export function parseBooleanFrontmatter(value: unknown): boolean {
  300. return value === true || value === 'true'
  301. }
  302. /**
  303. * Shell values accepted in `shell:` frontmatter for .md `!`-block execution.
  304. */
  305. export type FrontmatterShell = 'bash' | 'powershell'
  306. const FRONTMATTER_SHELLS: readonly FrontmatterShell[] = ['bash', 'powershell']
  307. /**
  308. * Parse and validate the `shell:` frontmatter field.
  309. *
  310. * Returns undefined for absent/null/empty (caller defaults to bash).
  311. * Logs a warning and returns undefined for unrecognized values — we fall
  312. * back to bash rather than failing the skill load, matching how `effort`
  313. * and other fields degrade.
  314. */
  315. export function parseShellFrontmatter(
  316. value: unknown,
  317. source: string,
  318. ): FrontmatterShell | undefined {
  319. if (value == null) {
  320. return undefined
  321. }
  322. const normalized = String(value).trim().toLowerCase()
  323. if (normalized === '') {
  324. return undefined
  325. }
  326. if ((FRONTMATTER_SHELLS as readonly string[]).includes(normalized)) {
  327. return normalized as FrontmatterShell
  328. }
  329. logForDebugging(
  330. `Frontmatter 'shell: ${value}' in ${source} is not recognized. Valid values: ${FRONTMATTER_SHELLS.join(', ')}. Falling back to bash.`,
  331. { level: 'warn' },
  332. )
  333. return undefined
  334. }