From b67cfe3a97534cfdf37176815027c7008a0002b2 Mon Sep 17 00:00:00 2001 From: Doug Lance <4741454+douglance@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:38:52 -0400 Subject: [PATCH 1/2] fix: YAML-quote frontmatter description to prevent parse errors Description values containing colons, backticks, and other YAML-special characters (e.g. "Run `app ping --help` for usage details.") break frontmatter parsing when left unquoted. This adds a `yamlQuote()` helper that wraps the value in double quotes when it contains any character that needs escaping, and strips quotes when reading the description back in index.json generation. --- src/Cli.ts | 3 ++- src/Skill.test.ts | 8 ++++---- src/Skill.ts | 10 +++++++++- src/e2e.test.ts | 2 +- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index f7a2f82..e2bd89e 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1566,9 +1566,10 @@ async function fetchImpl( const files = Skill.split(name, cmds, 1, groups) const skills = files.map((f) => { const descMatch = f.content.match(/^description:\s*(.+)$/m) + const rawDesc = descMatch?.[1] ?? '' return { name: f.dir || name, - description: descMatch?.[1] ?? '', + description: rawDesc.replace(/^"(.*)"$/, '$1'), files: ['SKILL.md'], } }) diff --git a/src/Skill.test.ts b/src/Skill.test.ts index 9410d23..50bc046 100644 --- a/src/Skill.test.ts +++ b/src/Skill.test.ts @@ -252,7 +252,7 @@ describe('split', () => { expect(files[0]!.content).toMatchInlineSnapshot(` "--- name: gh-auth - description: Authenticate with GitHub. Log in, Check status. Run \`gh auth --help\` for usage details. + description: "Authenticate with GitHub. Log in, Check status. Run \`gh auth --help\` for usage details." requires_bin: gh command: gh auth --- @@ -270,7 +270,7 @@ describe('split', () => { expect(files[1]!.content).toMatchInlineSnapshot(` "--- name: gh-pr - description: Manage pull requests. List PRs, Create PR. Run \`gh pr --help\` for usage details. + description: "Manage pull requests. List PRs, Create PR. Run \`gh pr --help\` for usage details." requires_bin: gh command: gh pr --- @@ -290,7 +290,7 @@ describe('split', () => { test('depth 1 without group descriptions uses child descriptions', () => { const files = Skill.split('gh', commands, 1) expect(files[0]!.content).toContain( - 'description: Log in, Check status. Run `gh auth --help` for usage details.', + 'description: "Log in, Check status. Run `gh auth --help` for usage details."', ) }) @@ -333,7 +333,7 @@ describe('split', () => { test('emits fallback description when no explicit descriptions exist', () => { const files = Skill.split('test', [{ name: 'ping' }], 1) - expect(files[0]!.content).toContain('description: Run `test ping --help` for usage details.') + expect(files[0]!.content).toContain('description: "Run `test ping --help` for usage details."') }) test('includes requires_bin in frontmatter', () => { diff --git a/src/Skill.ts b/src/Skill.ts index 3ec985f..131318a 100644 --- a/src/Skill.ts +++ b/src/Skill.ts @@ -133,7 +133,7 @@ function renderGroup( .replace(/-{2,}/g, '-') .replace(/^-|-$/g, '') const fm = ['---', `name: ${slug}`] - fm.push(`description: ${description}`) + fm.push(`description: ${yamlQuote(description)}`) fm.push(`requires_bin: ${cli}`) fm.push(`command: ${title}`, '---') @@ -234,6 +234,14 @@ function renderCommandBody(cli: string, cmd: CommandInfo, level = 1): string { return sections.join('\n\n') } +/** @internal YAML-quotes a string value if it contains characters that need escaping. */ +function yamlQuote(value: string): string { + if (/[:#\[\]{}&*!|>'"%@`]/.test(value) || value.startsWith('- ') || value.startsWith('? ')) { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` + } + return value +} + /** Computes a deterministic hash of command structure for staleness detection. */ export function hash(commands: CommandInfo[]): string { const data = commands.map((cmd) => ({ diff --git a/src/e2e.test.ts b/src/e2e.test.ts index b8a6a84..e62a150 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -2927,7 +2927,7 @@ describe('.well-known/skills', () => { expect(result.body).toMatchInlineSnapshot(` "--- name: app-ping - description: Health check. Run \`app ping --help\` for usage details. + description: "Health check. Run \`app ping --help\` for usage details." requires_bin: app command: app ping --- From fd46f62099bb9115996e546926d3f69d64cc829a Mon Sep 17 00:00:00 2001 From: Doug Lance <4741454+douglance@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:01:45 -0400 Subject: [PATCH 2/2] fix: use yaml library for frontmatter serialization/parsing The frontmatter description was built via string concatenation, which produces invalid YAML when the value contains colon-space sequences (e.g. "Use key: value"). Instead of hand-rolling quoting logic, use the `yaml` package (already a dependency) to serialize frontmatter and parse it back in index.json generation. This handles all YAML edge cases correctly. --- src/Cli.ts | 7 ++++--- src/Skill.test.ts | 16 ++++++++++++---- src/Skill.ts | 20 +++++++------------- src/e2e.test.ts | 2 +- 4 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index e2bd89e..e2462fa 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { estimateTokenCount, sliceByTokens } from 'tokenx' +import { parse as yamlParse } from 'yaml' import type { z } from 'zod' import * as Completions from './Completions.js' @@ -1565,11 +1566,11 @@ async function fetchImpl( if (segments[2] === 'index.json' && segments.length === 3) { const files = Skill.split(name, cmds, 1, groups) const skills = files.map((f) => { - const descMatch = f.content.match(/^description:\s*(.+)$/m) - const rawDesc = descMatch?.[1] ?? '' + const fmMatch = f.content.match(/^---\n([\s\S]*?)\n---/) + const meta = fmMatch ? (yamlParse(fmMatch[1]!) as Record) : {} return { name: f.dir || name, - description: rawDesc.replace(/^"(.*)"$/, '$1'), + description: meta.description ?? '', files: ['SKILL.md'], } }) diff --git a/src/Skill.test.ts b/src/Skill.test.ts index 50bc046..7ae0aa7 100644 --- a/src/Skill.test.ts +++ b/src/Skill.test.ts @@ -252,7 +252,7 @@ describe('split', () => { expect(files[0]!.content).toMatchInlineSnapshot(` "--- name: gh-auth - description: "Authenticate with GitHub. Log in, Check status. Run \`gh auth --help\` for usage details." + description: Authenticate with GitHub. Log in, Check status. Run \`gh auth --help\` for usage details. requires_bin: gh command: gh auth --- @@ -270,7 +270,7 @@ describe('split', () => { expect(files[1]!.content).toMatchInlineSnapshot(` "--- name: gh-pr - description: "Manage pull requests. List PRs, Create PR. Run \`gh pr --help\` for usage details." + description: Manage pull requests. List PRs, Create PR. Run \`gh pr --help\` for usage details. requires_bin: gh command: gh pr --- @@ -290,7 +290,7 @@ describe('split', () => { test('depth 1 without group descriptions uses child descriptions', () => { const files = Skill.split('gh', commands, 1) expect(files[0]!.content).toContain( - 'description: "Log in, Check status. Run `gh auth --help` for usage details."', + 'description: Log in, Check status. Run `gh auth --help` for usage details.', ) }) @@ -333,7 +333,7 @@ describe('split', () => { test('emits fallback description when no explicit descriptions exist', () => { const files = Skill.split('test', [{ name: 'ping' }], 1) - expect(files[0]!.content).toContain('description: "Run `test ping --help` for usage details."') + expect(files[0]!.content).toContain('description: Run `test ping --help` for usage details.') }) test('includes requires_bin in frontmatter', () => { @@ -341,6 +341,14 @@ describe('split', () => { expect(files[0]!.content).toContain('requires_bin: gh') }) + test('YAML-quotes description containing colon-space', () => { + const groups = new Map([['search', 'Search items. Use key: value for precision']]) + const files = Skill.split('app', [{ name: 'search list', description: 'List results' }], 1, groups) + expect(files[0]!.content).toContain( + 'description: "Search items. Use key: value for precision. List results. Run `app search --help` for usage details."', + ) + }) + test('no per-command frontmatter in split files', () => { const files = Skill.split('gh', commands, 1, groups) const afterFrontmatter = files[0]!.content.slice( diff --git a/src/Skill.ts b/src/Skill.ts index 131318a..495014d 100644 --- a/src/Skill.ts +++ b/src/Skill.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto' import type { z } from 'zod' +import { stringify as yamlStringify } from 'yaml' import * as Schema from './Schema.js' @@ -132,13 +133,14 @@ function renderGroup( .replace(/[^a-z0-9-]+/g, '-') .replace(/-{2,}/g, '-') .replace(/^-|-$/g, '') - const fm = ['---', `name: ${slug}`] - fm.push(`description: ${yamlQuote(description)}`) - fm.push(`requires_bin: ${cli}`) - fm.push(`command: ${title}`, '---') + const fm = yamlStringify( + { name: slug, description, requires_bin: cli, command: title }, + { lineWidth: 0 }, + ).trimEnd() + const fmBlock = `---\n${fm}\n---` const body = cmds.map((cmd) => renderCommandBody(cli, cmd)).join('\n\n---\n\n') - return `${fm.join('\n')}\n\n${body}` + return `${fmBlock}\n\n${body}` } /** @internal Renders a command's heading and sections without frontmatter. */ @@ -234,14 +236,6 @@ function renderCommandBody(cli: string, cmd: CommandInfo, level = 1): string { return sections.join('\n\n') } -/** @internal YAML-quotes a string value if it contains characters that need escaping. */ -function yamlQuote(value: string): string { - if (/[:#\[\]{}&*!|>'"%@`]/.test(value) || value.startsWith('- ') || value.startsWith('? ')) { - return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` - } - return value -} - /** Computes a deterministic hash of command structure for staleness detection. */ export function hash(commands: CommandInfo[]): string { const data = commands.map((cmd) => ({ diff --git a/src/e2e.test.ts b/src/e2e.test.ts index e62a150..b8a6a84 100644 --- a/src/e2e.test.ts +++ b/src/e2e.test.ts @@ -2927,7 +2927,7 @@ describe('.well-known/skills', () => { expect(result.body).toMatchInlineSnapshot(` "--- name: app-ping - description: "Health check. Run \`app ping --help\` for usage details." + description: Health check. Run \`app ping --help\` for usage details. requires_bin: app command: app ping ---