diff --git a/AGENTS.md b/AGENTS.md index e9d5c599..370a1c2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,14 @@ # AGENTS.md - Coding Agent Guidelines for Systematic +**Generated:** 2026-01-28 | **Commit:** d4bfa75 | **Branch:** main + ## Project Overview -OpenCode plugin providing systematic engineering workflows. Converts and adapts Claude Code agents, skills, and commands from Compound Engineering Plugin (CEP) to OpenCode. Includes skills, agents, and commands for structured AI-assisted development. +OpenCode plugin providing systematic engineering workflows. Converts/adapts Claude Code agents, skills, and commands from Compound Engineering Plugin (CEP) to OpenCode. + +**Key insight:** This repo has two distinct parts: +1. **TypeScript source** (`src/`) - Plugin logic, tools, config handling +2. **Bundled assets** (`skills/`, `agents/`, `commands/`) - OpenCode Markdown content shipped with npm package ## Build & Test Commands @@ -175,21 +181,28 @@ describe('module-name', () => { ``` systematic/ ├── src/ -│ ├── index.ts # Plugin entry point -│ ├── cli.ts # CLI entry point +│ ├── index.ts # Plugin entry point (SystematicPlugin) +│ ├── cli.ts # CLI entry point │ └── lib/ -│ ├── config.ts # Configuration loading -│ ├── converter.ts # CEP to OpenCode conversion -│ └── skills-core.ts # Skill discovery/resolution +│ ├── agents.ts # Agent discovery + frontmatter parsing +│ ├── bootstrap.ts # System prompt injection +│ ├── commands.ts # Command discovery + frontmatter parsing +│ ├── config.ts # JSONC config loading (project > user) +│ ├── config-handler.ts # OpenCode config hook (merges bundled → existing) +│ ├── converter.ts # CEP to OpenCode conversion +│ ├── frontmatter.ts # YAML frontmatter utilities +│ ├── skill-tool.ts # `systematic_skill` tool implementation +│ ├── skills.ts # Skill discovery + frontmatter parsing +│ └── walk-dir.ts # Recursive directory traversal ├── tests/ -│ ├── unit/ # Unit tests (bun test tests/unit) -│ └── integration/ # Integration tests -├── skills/ # Bundled skill definitions -├── agents/ # Bundled agent definitions -├── commands/ # Bundled command definitions -├── dist/ # Build output (git-ignored) -├── biome.json # Linter/formatter config -├── tsconfig.json # TypeScript config +│ ├── unit/ # Unit tests (bun test tests/unit) +│ └── integration/ # Integration tests +├── skills/ # Bundled OpenCode skill definitions (SKILL.md) +├── agents/ # Bundled OpenCode agent definitions (Markdown) +├── commands/ # Bundled OpenCode command definitions (Markdown) +├── dist/ # Build output (git-ignored) +├── biome.json # Biome linter/formatter config +├── tsconfig.json # TypeScript config └── package.json ``` diff --git a/biome.json b/biome.json index 64a2b1bf..577ca1c9 100644 --- a/biome.json +++ b/biome.json @@ -28,7 +28,6 @@ "includes": [ "**", "!**/dist", - "!**/lib", "!**/.opencode", "!**/node_modules", "!**/*.md" diff --git a/bun.lock b/bun.lock index e846e476..fb7333ae 100644 --- a/bun.lock +++ b/bun.lock @@ -5,12 +5,14 @@ "": { "name": "@fro.bot/systematic", "dependencies": { + "js-yaml": "^4.1.1", "jsonc-parser": "^3.3.0", }, "devDependencies": { "@biomejs/biome": "^2.0.0", "@opencode-ai/plugin": "^1.1.30", "@types/bun": "latest", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "conventional-changelog-conventionalcommits": "^9.0.0", "markdownlint-cli": "^0.47.0", @@ -115,6 +117,8 @@ "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], diff --git a/package.json b/package.json index 12f70ee7..a3a45ffd 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "@biomejs/biome": "^2.0.0", "@opencode-ai/plugin": "^1.1.30", "@types/bun": "latest", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "conventional-changelog-conventionalcommits": "^9.0.0", "markdownlint-cli": "^0.47.0", @@ -63,6 +64,7 @@ "typescript": "^5.7.0" }, "dependencies": { + "js-yaml": "^4.1.1", "jsonc-parser": "^3.3.0" }, "publishConfig": { diff --git a/src/lib/agents.ts b/src/lib/agents.ts index c438fccd..f38c23a8 100644 --- a/src/lib/agents.ts +++ b/src/lib/agents.ts @@ -1,5 +1,5 @@ +import { parseFrontmatter, stripFrontmatter } from './frontmatter.js' import { walkDir } from './walk-dir.js' -import { stripFrontmatter } from './frontmatter.js' export interface AgentFrontmatter { name: string @@ -27,28 +27,17 @@ export function findAgentsInDir(dir: string, maxDepth = 2): AgentInfo[] { } export function extractAgentFrontmatter(content: string): AgentFrontmatter { - const lines = content.split('\n') - - let inFrontmatter = false - let name = '' - let description = '' - - for (const line of lines) { - if (line.trim() === '---') { - if (inFrontmatter) break - inFrontmatter = true - continue - } - - if (inFrontmatter) { - const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/) - if (match) { - const [, key, value] = match - if (key === 'name') name = value.trim() - if (key === 'description') description = value.trim() - } - } + const { data, parseError } = parseFrontmatter<{ + name?: string + description?: string + }>(content) + + return { + name: !parseError && typeof data.name === 'string' ? data.name : '', + description: + !parseError && typeof data.description === 'string' + ? data.description + : '', + prompt: stripFrontmatter(content), } - - return { name, description, prompt: stripFrontmatter(content) } } diff --git a/src/lib/commands.ts b/src/lib/commands.ts index 23a26a4d..347cfde8 100644 --- a/src/lib/commands.ts +++ b/src/lib/commands.ts @@ -1,3 +1,4 @@ +import { parseFrontmatter } from './frontmatter.js' import { walkDir } from './walk-dir.js' export interface CommandFrontmatter { @@ -20,7 +21,9 @@ export function findCommandsInDir(dir: string, maxDepth = 2): CommandInfo[] { return entries.map((entry) => { const baseName = entry.name.replace(/\.md$/, '') - const commandName = entry.category ? `/${entry.category}:${baseName}` : `/${baseName}` + const commandName = entry.category + ? `/${entry.category}:${baseName}` + : `/${baseName}` return { name: commandName, file: entry.path, @@ -30,30 +33,23 @@ export function findCommandsInDir(dir: string, maxDepth = 2): CommandInfo[] { } export function extractCommandFrontmatter(content: string): CommandFrontmatter { - const lines = content.split('\n') - - let inFrontmatter = false - let name = '' - let description = '' - let argumentHint = '' - - for (const line of lines) { - if (line.trim() === '---') { - if (inFrontmatter) break - inFrontmatter = true - continue - } - - if (inFrontmatter) { - const match = line.match(/^(\w+(?:-\w+)*):\s*(.*)$/) - if (match) { - const [, key, value] = match - if (key === 'name') name = value.trim() - if (key === 'description') description = value.trim() - if (key === 'argument-hint') argumentHint = value.trim().replace(/^["']|["']$/g, '') - } - } + const { data, parseError } = parseFrontmatter<{ + name?: string + description?: string + 'argument-hint'?: string + }>(content) + + const argumentHintRaw = + !parseError && typeof data['argument-hint'] === 'string' + ? data['argument-hint'] + : '' + + return { + name: !parseError && typeof data.name === 'string' ? data.name : '', + description: + !parseError && typeof data.description === 'string' + ? data.description + : '', + argumentHint: argumentHintRaw.replace(/^["']|["']$/g, ''), } - - return { name, description, argumentHint } } diff --git a/src/lib/config-handler.ts b/src/lib/config-handler.ts index fcfb8186..bd0861ec 100644 --- a/src/lib/config-handler.ts +++ b/src/lib/config-handler.ts @@ -1,10 +1,10 @@ import type { AgentConfig, Config } from '@opencode-ai/sdk' +import { extractAgentFrontmatter, findAgentsInDir } from './agents.js' +import { extractCommandFrontmatter, findCommandsInDir } from './commands.js' import { loadConfig } from './config.js' import { convertFileWithCache } from './converter.js' import { stripFrontmatter } from './frontmatter.js' -import { extractAgentFrontmatter, findAgentsInDir } from './agents.js' -import { extractCommandFrontmatter, findCommandsInDir } from './commands.js' -import { type SkillInfo, findSkillsInDir } from './skills.js' +import { findSkillsInDir, type SkillInfo } from './skills.js' export interface ConfigHandlerDeps { directory: string @@ -15,9 +15,11 @@ export interface ConfigHandlerDeps { type CommandConfig = NonNullable[string] -function loadAgentAsConfig( - agentInfo: { name: string; file: string; category?: string } -): AgentConfig | null { +function loadAgentAsConfig(agentInfo: { + name: string + file: string + category?: string +}): AgentConfig | null { try { const converted = convertFileWithCache(agentInfo.file, 'agent', { source: 'bundled', @@ -34,11 +36,15 @@ function loadAgentAsConfig( } } -function loadCommandAsConfig( - commandInfo: { name: string; file: string; category?: string } -): CommandConfig | null { +function loadCommandAsConfig(commandInfo: { + name: string + file: string + category?: string +}): CommandConfig | null { try { - const converted = convertFileWithCache(commandInfo.file, 'command', { source: 'bundled' }) + const converted = convertFileWithCache(commandInfo.file, 'command', { + source: 'bundled', + }) const { name, description } = extractCommandFrontmatter(converted) const cleanName = commandInfo.name.replace(/^\//, '') @@ -52,11 +58,11 @@ function loadCommandAsConfig( } } -function loadSkillAsCommand( - skillInfo: SkillInfo -): CommandConfig | null { +function loadSkillAsCommand(skillInfo: SkillInfo): CommandConfig | null { try { - const converted = convertFileWithCache(skillInfo.skillFile, 'skill', { source: 'bundled' }) + const converted = convertFileWithCache(skillInfo.skillFile, 'skill', { + source: 'bundled', + }) return { template: stripFrontmatter(converted), @@ -69,7 +75,7 @@ function loadSkillAsCommand( function collectAgents( dir: string, - disabledAgents: string[] + disabledAgents: string[], ): NonNullable { const agents: NonNullable = {} const agentList = findAgentsInDir(dir) @@ -88,7 +94,7 @@ function collectAgents( function collectCommands( dir: string, - disabledCommands: string[] + disabledCommands: string[], ): NonNullable { const commands: NonNullable = {} const commandList = findCommandsInDir(dir) @@ -108,7 +114,7 @@ function collectCommands( function collectSkillsAsCommands( dir: string, - disabledSkills: string[] + disabledSkills: string[], ): NonNullable { const commands: NonNullable = {} const skillList = findSkillsInDir(dir) @@ -135,24 +141,25 @@ function collectSkillsAsCommands( * Existing OpenCode config is preserved and takes precedence. */ export function createConfigHandler(deps: ConfigHandlerDeps) { - const { directory, bundledSkillsDir, bundledAgentsDir, bundledCommandsDir } = deps + const { directory, bundledSkillsDir, bundledAgentsDir, bundledCommandsDir } = + deps return async (config: Config): Promise => { const systematicConfig = loadConfig(directory) const bundledAgents = collectAgents( bundledAgentsDir, - systematicConfig.disabled_agents + systematicConfig.disabled_agents, ) const bundledCommands = collectCommands( bundledCommandsDir, - systematicConfig.disabled_commands + systematicConfig.disabled_commands, ) const bundledSkills = collectSkillsAsCommands( bundledSkillsDir, - systematicConfig.disabled_skills + systematicConfig.disabled_skills, ) const existingAgents = config.agent ?? {} diff --git a/src/lib/config.ts b/src/lib/config.ts index 8835a510..81c75515 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,6 @@ import fs from 'node:fs' -import path from 'node:path' import os from 'node:os' +import path from 'node:path' import { parse as parseJsonc } from 'jsonc-parser' export interface BootstrapConfig { @@ -34,62 +34,46 @@ function loadJsoncFile(filePath: string): T | null { } } -function mergeArraysUnique(arr1: T[] | undefined, arr2: T[] | undefined): T[] { +function mergeArraysUnique( + arr1: T[] | undefined, + arr2: T[] | undefined, +): T[] { const set = new Set() - if (arr1) arr1.forEach((item) => set.add(item)) - if (arr2) arr2.forEach((item) => set.add(item)) + if (arr1) for (const item of arr1) set.add(item) + if (arr2) for (const item of arr2) set.add(item) return Array.from(set) } -function deepMerge>( - base: T, - ...overrides: Array | null> -): T { - const result = { ...base } - - for (const override of overrides) { - if (!override) continue - for (const [key, value] of Object.entries(override)) { - if ( - typeof value === 'object' && - value !== null && - !Array.isArray(value) && - typeof result[key] === 'object' && - result[key] !== null - ) { - ;(result as Record)[key] = deepMerge( - result[key] as Record, - value as Record - ) - } else if (value !== undefined) { - ;(result as Record)[key] = value - } - } - } - - return result -} - export function loadConfig(projectDir: string): SystematicConfig { const homeDir = os.homedir() const userConfigPath = path.join(homeDir, '.config/opencode/systematic.json') const projectConfigPath = path.join(projectDir, '.opencode/systematic.json') const userConfig = loadJsoncFile>(userConfigPath) - const projectConfig = loadJsoncFile>(projectConfigPath) + const projectConfig = + loadJsoncFile>(projectConfigPath) const result: SystematicConfig = { disabled_skills: mergeArraysUnique( - mergeArraysUnique(DEFAULT_CONFIG.disabled_skills, userConfig?.disabled_skills), - projectConfig?.disabled_skills + mergeArraysUnique( + DEFAULT_CONFIG.disabled_skills, + userConfig?.disabled_skills, + ), + projectConfig?.disabled_skills, ), disabled_agents: mergeArraysUnique( - mergeArraysUnique(DEFAULT_CONFIG.disabled_agents, userConfig?.disabled_agents), - projectConfig?.disabled_agents + mergeArraysUnique( + DEFAULT_CONFIG.disabled_agents, + userConfig?.disabled_agents, + ), + projectConfig?.disabled_agents, ), disabled_commands: mergeArraysUnique( - mergeArraysUnique(DEFAULT_CONFIG.disabled_commands, userConfig?.disabled_commands), - projectConfig?.disabled_commands + mergeArraysUnique( + DEFAULT_CONFIG.disabled_commands, + userConfig?.disabled_commands, + ), + projectConfig?.disabled_commands, ), bootstrap: { ...DEFAULT_CONFIG.bootstrap, diff --git a/src/lib/converter.ts b/src/lib/converter.ts index 3d76c7ca..e4cc31d7 100644 --- a/src/lib/converter.ts +++ b/src/lib/converter.ts @@ -1,5 +1,5 @@ import fs from 'node:fs' -import { parseFrontmatter, formatFrontmatter, type ParsedFrontmatter } from './frontmatter.js' +import { formatFrontmatter, parseFrontmatter } from './frontmatter.js' export type ContentType = 'skill' | 'agent' | 'command' export type SourceType = 'bundled' | 'external' @@ -19,10 +19,16 @@ const cache = new Map() function inferTemperature(name: string, description?: string): number { const sample = `${name} ${description ?? ''}`.toLowerCase() - if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) { + if ( + /(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test( + sample, + ) + ) { return 0.1 } - if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) { + if ( + /(plan|planning|architecture|strategist|analysis|research)/.test(sample) + ) { return 0.2 } if (/(doc|readme|changelog|editor|writer)/.test(sample)) { @@ -45,10 +51,11 @@ function normalizeModel(model: string): string { function transformAgentFrontmatter( data: Record, - agentMode: AgentMode + agentMode: AgentMode, ): Record { const name = typeof data.name === 'string' ? data.name : '' - const description = typeof data.description === 'string' ? data.description : '' + const description = + typeof data.description === 'string' ? data.description : '' const newData: Record = { description: description || `${name} agent`, @@ -71,14 +78,14 @@ function transformAgentFrontmatter( export function convertContent( content: string, type: ContentType, - options: ConvertOptions = {} + options: ConvertOptions = {}, ): string { if (content === '') return '' - const { data, body, raw } = parseFrontmatter(content) - const hasFrontmatter = raw !== '' + const { data, body, hadFrontmatter } = + parseFrontmatter>(content) - if (!hasFrontmatter) { + if (!hadFrontmatter) { return content } @@ -94,7 +101,7 @@ export function convertContent( export function convertFileWithCache( filePath: string, type: ContentType, - options: ConvertOptions = {} + options: ConvertOptions = {}, ): string { const fd = fs.openSync(filePath, 'r') try { diff --git a/src/lib/frontmatter.ts b/src/lib/frontmatter.ts index 6777c80c..7392297e 100644 --- a/src/lib/frontmatter.ts +++ b/src/lib/frontmatter.ts @@ -1,49 +1,51 @@ -interface ParsedFrontmatter { - data: Record +import yaml from 'js-yaml' + +export interface FrontmatterResult> { + data: T body: string - raw: string + hadFrontmatter: boolean + parseError: boolean } -export type { ParsedFrontmatter } - -export function parseFrontmatter(content: string): ParsedFrontmatter { - const lines = content.split(/\r?\n/) - if (lines.length === 0 || lines[0].trim() !== '---') { - return { data: {}, body: content, raw: '' } - } - - let endIndex = -1 - for (let i = 1; i < lines.length; i++) { - if (lines[i].trim() === '---') { - endIndex = i - break +/** + * Parses YAML frontmatter from Markdown content. + * + * Uses js-yaml with JSON_SCHEMA for security (prevents code execution via YAML tags). + * Supports all standard YAML keys including hyphenated ones (e.g., 'argument-hint'). + * + * @param content - Markdown content with optional frontmatter + * @returns Parsed frontmatter data, body content, and parsing status + */ +export function parseFrontmatter>( + content: string, +): FrontmatterResult { + const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/ + const match = content.match(frontmatterRegex) + + if (!match) { + return { + data: {} as T, + body: content, + hadFrontmatter: false, + parseError: false, } } - if (endIndex === -1) { - return { data: {}, body: content, raw: '' } - } - - const yamlLines = lines.slice(1, endIndex) - const body = lines.slice(endIndex + 1).join('\n') - const raw = lines.slice(0, endIndex + 1).join('\n') - const data: Record = {} + const yamlContent = match[1] + const body = match[2] - for (const line of yamlLines) { - const match = line.match(/^([\w-]+):\s*(.*)$/) - if (match) { - const [, key, value] = match - if (value === 'true') data[key] = true - else if (value === 'false') data[key] = false - else if (/^\d+(\.\d+)?$/.test(value)) data[key] = parseFloat(value) - else data[key] = value - } + try { + const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) + const data = (parsed ?? {}) as T + return { data, body, hadFrontmatter: true, parseError: false } + } catch { + return { data: {} as T, body, hadFrontmatter: true, parseError: true } } - - return { data, body, raw } } -export function formatFrontmatter(data: Record): string { +export function formatFrontmatter( + data: Record, +): string { const lines: string[] = ['---'] for (const [key, value] of Object.entries(data)) { lines.push(`${key}: ${value}`) @@ -53,25 +55,6 @@ export function formatFrontmatter(data: Record { @@ -87,14 +86,14 @@ ${wrapped}` const errorMessage = error instanceof Error ? error.message : String(error) throw new Error( - `Failed to load skill "${requestedName}": ${errorMessage}` + `Failed to load skill "${requestedName}": ${errorMessage}`, ) } } const availableSystematic = skills.map((s) => `systematic:${s.name}`) throw new Error( - `Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}` + `Skill "${requestedName}" not found. Available systematic skills: ${availableSystematic.join(', ')}`, ) }, }) diff --git a/src/lib/skills.ts b/src/lib/skills.ts index 619793be..8253b132 100644 --- a/src/lib/skills.ts +++ b/src/lib/skills.ts @@ -1,5 +1,6 @@ import fs from 'node:fs' import path from 'node:path' +import { parseFrontmatter } from './frontmatter.js' import { walkDir } from './walk-dir.js' export interface SkillFrontmatter { @@ -17,30 +18,19 @@ export interface SkillInfo { export function extractFrontmatter(filePath: string): SkillFrontmatter { try { const content = fs.readFileSync(filePath, 'utf8') - const lines = content.split('\n') + const { data, parseError } = parseFrontmatter<{ + name?: string + description?: string + }>(content) - let inFrontmatter = false - let name = '' - let description = '' - - for (const line of lines) { - if (line.trim() === '---') { - if (inFrontmatter) break - inFrontmatter = true - continue - } - - if (inFrontmatter) { - const match = line.match(/^(\w+):\s*(.*)$/) - if (match) { - const [, key, value] = match - if (key === 'name') name = value.trim() - if (key === 'description') description = value.trim() - } - } + if (parseError) { + return { name: '', description: '' } } - return { name, description } + return { + name: typeof data.name === 'string' ? data.name : '', + description: typeof data.description === 'string' ? data.description : '', + } } catch { return { name: '', description: '' } } diff --git a/src/lib/walk-dir.ts b/src/lib/walk-dir.ts index b3c74d6c..0f37338b 100644 --- a/src/lib/walk-dir.ts +++ b/src/lib/walk-dir.ts @@ -14,7 +14,10 @@ export interface WalkOptions { filter?: (entry: WalkEntry) => boolean } -export function walkDir(rootDir: string, options: WalkOptions = {}): WalkEntry[] { +export function walkDir( + rootDir: string, + options: WalkOptions = {}, +): WalkEntry[] { const { maxDepth = 3, filter } = options const results: WalkEntry[] = [] diff --git a/tests/unit/frontmatter.test.ts b/tests/unit/frontmatter.test.ts new file mode 100644 index 00000000..3e0ce972 --- /dev/null +++ b/tests/unit/frontmatter.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from 'bun:test' +import { + formatFrontmatter, + parseFrontmatter, + stripFrontmatter, +} from '../../src/lib/frontmatter.ts' + +describe('frontmatter', () => { + describe('parseFrontmatter', () => { + describe('valid frontmatter', () => { + test('parses simple key-value pairs', () => { + const content = `--- +name: test-skill +description: A test skill +--- +# Content` + const result = parseFrontmatter(content) + expect(result.hadFrontmatter).toBe(true) + expect(result.parseError).toBe(false) + expect(result.data).toEqual({ + name: 'test-skill', + description: 'A test skill', + }) + expect(result.body).toBe('# Content') + }) + + test('parses hyphenated keys', () => { + const content = `--- +argument-hint: "[file path]" +allowed-tools: Read, Write +--- +Body` + const result = parseFrontmatter<{ + 'argument-hint': string + 'allowed-tools': string + }>(content) + expect(result.hadFrontmatter).toBe(true) + expect(result.parseError).toBe(false) + expect(result.data['argument-hint']).toBe('[file path]') + expect(result.data['allowed-tools']).toBe('Read, Write') + }) + + test('parses boolean values', () => { + const content = `--- +enabled: true +disabled: false +--- +Body` + const result = parseFrontmatter<{ + enabled: boolean + disabled: boolean + }>(content) + expect(result.data.enabled).toBe(true) + expect(result.data.disabled).toBe(false) + }) + + test('parses numeric values', () => { + const content = `--- +temperature: 0.7 +count: 42 +--- +Body` + const result = parseFrontmatter<{ temperature: number; count: number }>( + content, + ) + expect(result.data.temperature).toBe(0.7) + expect(result.data.count).toBe(42) + }) + + test('parses quoted strings', () => { + const content = `--- +name: "quoted value" +description: 'single quoted' +--- +Body` + const result = parseFrontmatter(content) + expect(result.data).toEqual({ + name: 'quoted value', + description: 'single quoted', + }) + }) + + test('preserves body content exactly', () => { + const content = `--- +name: test +--- +Line 1 +Line 2 + +Line 4` + const result = parseFrontmatter(content) + expect(result.body).toBe('Line 1\nLine 2\n\nLine 4') + }) + + test('handles Windows line endings', () => { + const content = '---\r\nname: test\r\n---\r\nBody content' + const result = parseFrontmatter(content) + expect(result.hadFrontmatter).toBe(true) + expect(result.data).toEqual({ name: 'test' }) + expect(result.body).toBe('Body content') + }) + }) + + describe('edge cases', () => { + test('returns hadFrontmatter: false when no delimiters', () => { + const content = '# Just a heading\nSome content' + const result = parseFrontmatter(content) + expect(result.hadFrontmatter).toBe(false) + expect(result.parseError).toBe(false) + expect(result.body).toBe(content) + expect(result.data).toEqual({}) + }) + + test('returns hadFrontmatter: false when only opening delimiter', () => { + const content = '---\nname: test\n# No closing delimiter' + const result = parseFrontmatter(content) + expect(result.hadFrontmatter).toBe(false) + expect(result.body).toBe(content) + }) + + test('handles empty frontmatter', () => { + const content = `--- +--- +# Content` + const result = parseFrontmatter(content) + expect(result.hadFrontmatter).toBe(true) + expect(result.parseError).toBe(false) + expect(result.data).toEqual({}) + }) + + test('handles empty content', () => { + const result = parseFrontmatter('') + expect(result.hadFrontmatter).toBe(false) + expect(result.body).toBe('') + expect(result.data).toEqual({}) + }) + + test('handles frontmatter with only newline before closing', () => { + const content = `--- +name: test +--- +Body` + const result = parseFrontmatter(content) + expect(result.hadFrontmatter).toBe(true) + expect(result.data).toEqual({ name: 'test' }) + }) + }) + + describe('error handling', () => { + test('sets parseError: true for malformed YAML', () => { + const content = `--- +key: value + invalid: indentation +--- +Body` + const result = parseFrontmatter(content) + expect(result.hadFrontmatter).toBe(true) + expect(result.parseError).toBe(true) + expect(result.data).toEqual({}) + }) + + test('returns empty data on parse error', () => { + const content = `--- +[invalid yaml +--- +Body` + const result = parseFrontmatter(content) + expect(result.parseError).toBe(true) + expect(result.data).toEqual({}) + expect(result.body).toBe('Body') + }) + }) + + describe('generic type support', () => { + interface SkillFrontmatter { + name: string + description: string + } + + test('supports typed frontmatter extraction', () => { + const content = `--- +name: my-skill +description: A skill description +--- +Body` + const result = parseFrontmatter(content) + expect(result.data.name).toBe('my-skill') + expect(result.data.description).toBe('A skill description') + }) + }) + }) + + describe('formatFrontmatter', () => { + test('formats key-value pairs', () => { + const data = { name: 'test', count: 5, enabled: true } + const result = formatFrontmatter(data) + expect(result).toBe('---\nname: test\ncount: 5\nenabled: true\n---') + }) + + test('handles empty data', () => { + const result = formatFrontmatter({}) + expect(result).toBe('---\n---') + }) + }) + + describe('stripFrontmatter', () => { + test('removes frontmatter from content', () => { + const content = `--- +name: test +--- +# Content Here` + const result = stripFrontmatter(content) + expect(result).toBe('# Content Here') + }) + + test('returns content unchanged if no frontmatter', () => { + const content = '# No frontmatter' + const result = stripFrontmatter(content) + expect(result).toBe('# No frontmatter') + }) + + test('trims whitespace from body', () => { + const content = `--- +name: test +--- + +Content with leading newline` + const result = stripFrontmatter(content) + expect(result).toBe('Content with leading newline') + }) + }) +})