/** * Frontmatter parser for markdown files * Extracts and parses YAML frontmatter between --- delimiters */ import { logForDebugging } from './debug.js' import type { HooksSettings } from './settings/types.js' import { parseYaml } from './yaml.js' export type FrontmatterData = { // YAML can return null for keys with no value (e.g., "key:" with nothing after) 'allowed-tools'?: string | string[] | null description?: string | null // Memory type: 'user', 'feedback', 'project', or 'reference' // Only applicable to memory files; narrowed via parseMemoryType() in src/memdir/memoryTypes.ts type?: string | null 'argument-hint'?: string | null when_to_use?: string | null version?: string | null // Only applicable to slash commands -- a string similar to a boolean env var // to determine whether to make them visible to the SlashCommand tool. 'hide-from-slash-command-tool'?: string | null // Model alias or name (e.g., 'haiku', 'sonnet', 'opus', or specific model names) // Use 'inherit' for commands to use the parent model model?: string | null // Comma-separated list of skill names to preload (only applicable to agents) skills?: string | null // Whether users can invoke this skill by typing /skill-name // 'true' = user can type /skill-name to invoke // 'false' = only model can invoke via Skill tool // Default depends on source: commands/ defaults to true, skills/ defaults to false 'user-invocable'?: string | null // Hooks to register when this skill is invoked // Keys are hook events (PreToolUse, PostToolUse, Stop, etc.) // Values are arrays of matcher configurations with hooks // Validated by HooksSchema in loadSkillsDir.ts hooks?: HooksSettings | null // Effort level for agents (e.g., 'low', 'medium', 'high', 'max', or an integer) // Controls the thinking effort used by the agent's model effort?: string | null // Execution context for skills: 'inline' (default) or 'fork' (run as sub-agent) // 'inline' = skill content expands into the current conversation // 'fork' = skill runs in a sub-agent with separate context and token budget context?: 'inline' | 'fork' | null // Agent type to use when forked (e.g., 'Bash', 'general-purpose') // Only applicable when context is 'fork' agent?: string | null // Glob patterns for file paths this skill applies to. Accepts either a // comma-separated string or a YAML list of strings. // When set, the skill is only activated when the model touches matching files // Uses the same format as CLAUDE.md paths frontmatter paths?: string | string[] | null // Shell to use for !`cmd` and ```! blocks in skill/command .md content. // 'bash' (default) or 'powershell'. File-scoped — applies to all !-blocks. // Never consults settings.defaultShell: skills are portable across platforms, // so the author picks the shell, not the reader. See docs/design/ps-shell-selection.md §5.3. shell?: string | null [key: string]: unknown } export type ParsedMarkdown = { frontmatter: FrontmatterData content: string } // Characters that require quoting in YAML values (when unquoted) // - { } are flow mapping indicators // - * is anchor/alias indicator // - [ ] are flow sequence indicators // - ': ' (colon followed by space) is key indicator — causes 'Nested mappings // are not allowed in compact mappings' when it appears mid-value. Match the // pattern rather than bare ':' so '12:34' times and 'https://' URLs stay unquoted. // - # is comment indicator // - & is anchor indicator // - ! is tag indicator // - | > are block scalar indicators (only at start) // - % is directive indicator (only at start) // - @ ` are reserved const YAML_SPECIAL_CHARS = /[{}[\]*&#!|>%@`]|: / /** * Pre-processes frontmatter text to quote values that contain special YAML characters. * This allows glob patterns like **\/*.{ts,tsx} to be parsed correctly. */ function quoteProblematicValues(frontmatterText: string): string { const lines = frontmatterText.split('\n') const result: string[] = [] for (const line of lines) { // Match simple key: value lines (not indented, not list items, not block scalars) const match = line.match(/^([a-zA-Z_-]+):\s+(.+)$/) if (match) { const [, key, value] = match if (!key || !value) { result.push(line) continue } // Skip if already quoted if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { result.push(line) continue } // Quote if contains special YAML characters if (YAML_SPECIAL_CHARS.test(value)) { // Use double quotes and escape any existing double quotes const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') result.push(`${key}: "${escaped}"`) continue } } result.push(line) } return result.join('\n') } export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/ /** * Parses markdown content to extract frontmatter and content * @param markdown The raw markdown content * @returns Object containing parsed frontmatter and content without frontmatter */ export function parseFrontmatter( markdown: string, sourcePath?: string, ): ParsedMarkdown { const match = markdown.match(FRONTMATTER_REGEX) if (!match) { // No frontmatter found return { frontmatter: {}, content: markdown, } } const frontmatterText = match[1] || '' const content = markdown.slice(match[0].length) let frontmatter: FrontmatterData = {} try { const parsed = parseYaml(frontmatterText) as FrontmatterData | null if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { frontmatter = parsed } } catch { // YAML parsing failed - try again after quoting problematic values try { const quotedText = quoteProblematicValues(frontmatterText) const parsed = parseYaml(quotedText) as FrontmatterData | null if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { frontmatter = parsed } } catch (retryError) { // Still failed - log for debugging so users can diagnose broken frontmatter const location = sourcePath ? ` in ${sourcePath}` : '' logForDebugging( `Failed to parse YAML frontmatter${location}: ${retryError instanceof Error ? retryError.message : retryError}`, { level: 'warn' }, ) } } return { frontmatter, content, } } /** * Splits a comma-separated string and expands brace patterns. * Commas inside braces are not treated as separators. * Also accepts a YAML list (string array) for ergonomic frontmatter. * @param input - Comma-separated string, or array of strings, with optional brace patterns * @returns Array of expanded strings * @example * splitPathInFrontmatter("a, b") // returns ["a", "b"] * splitPathInFrontmatter("a, src/*.{ts,tsx}") // returns ["a", "src/*.ts", "src/*.tsx"] * splitPathInFrontmatter("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"] * splitPathInFrontmatter(["a", "src/*.{ts,tsx}"]) // returns ["a", "src/*.ts", "src/*.tsx"] */ export function splitPathInFrontmatter(input: string | string[]): string[] { if (Array.isArray(input)) { return input.flatMap(splitPathInFrontmatter) } if (typeof input !== 'string') { return [] } // Split by comma while respecting braces const parts: string[] = [] let current = '' let braceDepth = 0 for (let i = 0; i < input.length; i++) { const char = input[i] if (char === '{') { braceDepth++ current += char } else if (char === '}') { braceDepth-- current += char } else if (char === ',' && braceDepth === 0) { // Split here - we're at a comma outside of braces const trimmed = current.trim() if (trimmed) { parts.push(trimmed) } current = '' } else { current += char } } // Add the last part const trimmed = current.trim() if (trimmed) { parts.push(trimmed) } // Expand brace patterns in each part return parts .filter(p => p.length > 0) .flatMap(pattern => expandBraces(pattern)) } /** * Expands brace patterns in a glob string. * @example * expandBraces("src/*.{ts,tsx}") // returns ["src/*.ts", "src/*.tsx"] * expandBraces("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"] */ function expandBraces(pattern: string): string[] { // Find the first brace group const braceMatch = pattern.match(/^([^{]*)\{([^}]+)\}(.*)$/) if (!braceMatch) { // No braces found, return pattern as-is return [pattern] } const prefix = braceMatch[1] || '' const alternatives = braceMatch[2] || '' const suffix = braceMatch[3] || '' // Split alternatives by comma and expand each one const parts = alternatives.split(',').map(alt => alt.trim()) // Recursively expand remaining braces in suffix const expanded: string[] = [] for (const part of parts) { const combined = prefix + part + suffix // Recursively handle additional brace groups const furtherExpanded = expandBraces(combined) expanded.push(...furtherExpanded) } return expanded } /** * Parses a positive integer value from frontmatter. * Handles both number and string representations. * * @param value The raw value from frontmatter (could be number, string, or undefined) * @returns The parsed positive integer, or undefined if invalid or not provided */ export function parsePositiveIntFromFrontmatter( value: unknown, ): number | undefined { if (value === undefined || value === null) { return undefined } const parsed = typeof value === 'number' ? value : parseInt(String(value), 10) if (Number.isInteger(parsed) && parsed > 0) { return parsed } return undefined } /** * Validate and coerce a description value from frontmatter. * * Strings are returned as-is (trimmed). Primitive values (numbers, booleans) * are coerced to strings via String(). Non-scalar values (arrays, objects) * are invalid and are logged then omitted. Null, undefined, and * empty/whitespace-only strings return null so callers can fall back to * a default. * * @param value - The raw frontmatter description value * @param componentName - The skill/command/agent/style name for log messages * @param pluginName - The plugin name, if this came from a plugin */ export function coerceDescriptionToString( value: unknown, componentName?: string, pluginName?: string, ): string | null { if (value == null) { return null } if (typeof value === 'string') { return value.trim() || null } if (typeof value === 'number' || typeof value === 'boolean') { return String(value) } // Non-scalar descriptions (arrays, objects) are invalid — log and omit const source = pluginName ? `${pluginName}:${componentName}` : (componentName ?? 'unknown') logForDebugging(`Description invalid for ${source} - omitting`, { level: 'warn', }) return null } /** * Parse a boolean frontmatter value. * Only returns true for literal true or "true" string. */ export function parseBooleanFrontmatter(value: unknown): boolean { return value === true || value === 'true' } /** * Shell values accepted in `shell:` frontmatter for .md `!`-block execution. */ export type FrontmatterShell = 'bash' | 'powershell' const FRONTMATTER_SHELLS: readonly FrontmatterShell[] = ['bash', 'powershell'] /** * Parse and validate the `shell:` frontmatter field. * * Returns undefined for absent/null/empty (caller defaults to bash). * Logs a warning and returns undefined for unrecognized values — we fall * back to bash rather than failing the skill load, matching how `effort` * and other fields degrade. */ export function parseShellFrontmatter( value: unknown, source: string, ): FrontmatterShell | undefined { if (value == null) { return undefined } const normalized = String(value).trim().toLowerCase() if (normalized === '') { return undefined } if ((FRONTMATTER_SHELLS as readonly string[]).includes(normalized)) { return normalized as FrontmatterShell } logForDebugging( `Frontmatter 'shell: ${value}' in ${source} is not recognized. Valid values: ${FRONTMATTER_SHELLS.join(', ')}. Falling back to bash.`, { level: 'warn' }, ) return undefined }