init
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user