diff --git a/AGENTS.md b/AGENTS.md index 8110dae..593e71e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ ## Documentation Conventions - **JSDoc on all exports** — every exported function, type, and constant gets a JSDoc comment. Type properties get JSDoc too. Namespace types (e.g. `declare namespace create { type Options }`) get JSDoc too. Doc-driven development: write the JSDoc before or alongside the implementation, not after. +- **Parse structured frontmatter structurally** — when `SKILL.md` frontmatter is emitted as YAML, read it back with the YAML parser instead of regex-scraping individual fields. ## Testing Conventions diff --git a/src/Cli.ts b/src/Cli.ts index 04d1c0c..ea7e274 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 { z } from 'zod' import * as Completions from './Completions.js' @@ -1629,10 +1630,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 fmMatch = f.content.match(/^---\n([\s\S]*?)\n---/) + const meta = fmMatch ? (yamlParse(fmMatch[1]!) as Record) : {} return { name: f.dir || name, - description: descMatch?.[1] ?? '', + description: meta.description ?? '', files: ['SKILL.md'], } }) diff --git a/src/Skill.test.ts b/src/Skill.test.ts index bcdc6a5..f18e596 100644 --- a/src/Skill.test.ts +++ b/src/Skill.test.ts @@ -373,6 +373,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. 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 9357f29..db12a9f 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' @@ -136,13 +137,14 @@ function renderGroup( ? `${desc.replace(/\.$/, '')}. Run \`${title} --help\` for usage details.` : `Run \`${title} --help\` for usage details.` - const fm = ['---', `name: ${slugify(title)}`] - fm.push(`description: ${description}`) - fm.push(`requires_bin: ${cli}`) - fm.push(`command: ${title}`, '---') + const fm = yamlStringify( + { name: slugify(title), 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. */ diff --git a/src/SyncSkills.test.ts b/src/SyncSkills.test.ts index 02c0eef..530be61 100644 --- a/src/SyncSkills.test.ts +++ b/src/SyncSkills.test.ts @@ -161,6 +161,37 @@ test('installed SKILL.md contains frontmatter', async () => { rmSync(tmp, { recursive: true, force: true }) }) +test('sync returns unquoted descriptions from YAML frontmatter', async () => { + const tmp = join(tmpdir(), `clac-quoted-description-test-${Date.now()}`) + mkdirSync(tmp, { recursive: true }) + + const search = Cli.create('search', { description: 'Search items. Use key: value for precision' }) + search.command('list', { description: 'List results', run: () => ({}) }) + + const cli = Cli.create('app') + cli.command('search', search) + + const commands = Cli.toCommands.get(cli)! + const installDir = join(tmp, 'install') + mkdirSync(join(installDir, '.agents', 'skills'), { recursive: true }) + + const result = await SyncSkills.sync('app', commands, { + global: false, + cwd: installDir, + }) + + expect(result.skills).toMatchInlineSnapshot(` + [ + { + "description": "Search items. Use key: value for precision. Run \`app search --help\` for usage details.", + "name": "app-search", + }, + ] + `) + + rmSync(tmp, { recursive: true, force: true }) +}) + test('list returns skills from command map', async () => { const cli = Cli.create('test', { description: 'A test CLI' }) cli.command('ping', { description: 'Health check', run: () => ({}) }) diff --git a/src/SyncSkills.ts b/src/SyncSkills.ts index 0c16e1f..ea5c855 100644 --- a/src/SyncSkills.ts +++ b/src/SyncSkills.ts @@ -2,6 +2,7 @@ import fsSync from 'node:fs' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' +import { parse as yamlParse } from 'yaml' import { formatExamples } from './Cli.js' import * as Agents from './internal/agents.js' @@ -30,9 +31,8 @@ export async function sync( : path.join(tmpDir, 'SKILL.md') await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, `${file.content}\n`) - const nameMatch = file.content.match(/^name:\s*(.+)$/m) - const descMatch = file.content.match(/^description:\s*(.+)$/m) - skills.push({ name: nameMatch?.[1] ?? (file.dir || name), description: descMatch?.[1] }) + const meta = parseFrontmatter(file.content) + skills.push({ name: meta.name ?? (file.dir || name), description: meta.description }) } // Include additional SKILL.md files matched by glob patterns @@ -42,16 +42,14 @@ export async function sync( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const nameMatch = content.match(/^name:\s*(.+)$/m) + const meta = parseFrontmatter(content) const skillName = - pattern === '_root' ? (nameMatch?.[1] ?? name) : path.basename(path.dirname(match)) + pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) const dest = path.join(tmpDir, skillName, 'SKILL.md') await fs.mkdir(path.dirname(dest), { recursive: true }) await fs.writeFile(dest, content) - if (!skills.some((s) => s.name === skillName)) { - const descMatch = content.match(/^description:\s*(.+)$/m) - skills.push({ name: skillName, description: descMatch?.[1], external: true }) - } + if (!skills.some((s) => s.name === skillName)) + skills.push({ name: skillName, description: meta.description, external: true }) } catch {} } } @@ -148,12 +146,11 @@ export async function list( const installed = readInstalledSkills(name, { cwd }) for (const file of files) { - const nameMatch = file.content.match(/^name:\s*(.+)$/m) - const descMatch = file.content.match(/^description:\s*(.+)$/m) - const skillName = nameMatch?.[1] ?? (file.dir || name) + const meta = parseFrontmatter(file.content) + const skillName = meta.name ?? (file.dir || name) skills.push({ name: skillName, - description: descMatch?.[1], + description: meta.description, installed: installed.has(skillName), }) } @@ -165,14 +162,12 @@ export async function list( for await (const match of fs.glob(globPattern, { cwd })) { try { const content = await fs.readFile(path.resolve(cwd, match), 'utf8') - const nameMatch = content.match(/^name:\s*(.+)$/m) - const skillName = - pattern === '_root' ? (nameMatch?.[1] ?? name) : path.basename(path.dirname(match)) + const meta = parseFrontmatter(content) + const skillName = pattern === '_root' ? (meta.name ?? name) : path.basename(path.dirname(match)) if (!skills.some((s) => s.name === skillName)) { - const descMatch = content.match(/^description:\s*(.+)$/m) skills.push({ name: skillName, - description: descMatch?.[1], + description: meta.description, installed: installed.has(skillName), }) } @@ -284,6 +279,15 @@ function collectEntries( return result.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? '')) } +function parseFrontmatter(content: string): { description?: string | undefined; name?: string | undefined } { + const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!match) return {} + + const meta = yamlParse(match[1]!) + if (!meta || typeof meta !== 'object') return {} + return meta as { description?: string | undefined; name?: string | undefined } +} + /** Resolves the package root from the executing bin script (`process.argv[1]`). Walks up from the bin's directory looking for `package.json`. Falls back to `process.cwd()`. */ function resolvePackageRoot(): string { const bin = process.argv[1]