| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370 |
- /**
- * 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
- }
|