| 104 | |
| 105 | /** Parse a SKILL.md into a SkillSpec. Returns null if the spec is unusable. */ |
| 106 | export function parseSkill( |
| 107 | raw: string, |
| 108 | name: string, |
| 109 | dir: string, |
| 110 | origin: SkillSpec['origin'], |
| 111 | ): SkillSpec | null { |
| 112 | let body = raw; |
| 113 | const fmMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\r?\n?([\s\S]*)$/); |
| 114 | const fm = fmMatch ? fmMatch[1] ?? '' : ''; |
| 115 | if (fmMatch) body = fmMatch[2] ?? ''; |
| 116 | |
| 117 | const fields = parseFrontmatter(fm); |
| 118 | |
| 119 | const description = (fields.description as string | undefined) ?? ''; |
| 120 | if (!description) { |
| 121 | logger.warn('Skill missing description, skipping', { name, dir }); |
| 122 | return null; |
| 123 | } |
| 124 | |
| 125 | // name in frontmatter is advisory — directory name wins so installer collisions stay safe. |
| 126 | return { |
| 127 | name, |
| 128 | dir, |
| 129 | origin, |
| 130 | description, |
| 131 | version: fields.version as string | undefined, |
| 132 | body: body.trim(), |
| 133 | allowedTools: asStringArray(fields['allowed-tools'] ?? fields['allowed_tools']), |
| 134 | triggers: asStringArray(fields.triggers), |
| 135 | slashAliases: asStringArray(fields['slash-aliases'] ?? fields['slash_aliases']), |
| 136 | model: fields.model as string | undefined, |
| 137 | files: asStringArray(fields.files), |
| 138 | author: fields.author as string | undefined, |
| 139 | source: fields.source as string | undefined, |
| 140 | // Anti-self-congratulation: an ABSENT provenance means a human authored/installed |
| 141 | // it → protected. Only the capture loop writes `provenance: machine`. |
| 142 | provenance: fields.provenance === 'machine' ? 'machine' : 'user', |
| 143 | humanEdited: String(fields['humanedited'] ?? fields['human-edited'] ?? '').toLowerCase() === 'true', |
| 144 | status: fields.status === 'candidate' ? 'candidate' : 'active', |
| 145 | }; |
| 146 | } |
| 147 | |
| 148 | /** Minimal YAML-ish parser. Same shape as custom-commands.ts for consistency. */ |
| 149 | function parseFrontmatter(fm: string): Record<string, string | string[]> { |