Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, string>) : {}
return {
name: f.dir || name,
description: descMatch?.[1] ?? '',
description: meta.description ?? '',
files: ['SKILL.md'],
}
})
Expand Down
8 changes: 8 additions & 0 deletions src/Skill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 7 additions & 5 deletions src/Skill.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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. */
Expand Down
31 changes: 31 additions & 0 deletions src/SyncSkills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({}) })
Expand Down
40 changes: 22 additions & 18 deletions src/SyncSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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'
Expand Down Expand Up @@ -30,9 +31,8 @@
: 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
Expand All @@ -42,16 +42,14 @@
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 {}
}
}
Expand Down Expand Up @@ -148,12 +146,11 @@
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),
})
}
Expand All @@ -165,14 +162,12 @@
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)

Check warning on line 165 in src/SyncSkills.ts

View check run for this annotation

Codecov / codecov/patch

src/SyncSkills.ts#L165

Added line #L165 was not covered by tests
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),
})
}
Expand Down Expand Up @@ -284,6 +279,15 @@
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]
Expand Down
Loading