import { readdirSync, readFileSync, statSync } from "fs" import path from "path" import { describe, expect, test } from "bun:test" import { load } from "js-yaml" import { formatFrontmatter, parseFrontmatter } from "../src/utils/frontmatter" describe("frontmatter", () => { test("parseFrontmatter returns body when no frontmatter", () => { const raw = "Hello\nWorld" const result = parseFrontmatter(raw) expect(result.data).toEqual({}) expect(result.body).toBe(raw) }) test("formatFrontmatter round trips", () => { const body = "Body text" const formatted = formatFrontmatter({ name: "agent", description: "Test" }, body) const parsed = parseFrontmatter(formatted) expect(parsed.data.name).toBe("agent") expect(parsed.data.description).toBe("Test") expect(parsed.body.trim()).toBe(body) }) }) /** * Collect all markdown files with YAML frontmatter from a plugin directory. * Returns [relativePath, yamlText] pairs for each file with a frontmatter block. */ function collectFrontmatterFiles(pluginRoot: string): [string, string][] { const results: [string, string][] = [] function walk(dir: string) { for (const entry of readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name) if (entry.isDirectory()) { if (entry.name === "node_modules" || entry.name === ".git") continue walk(full) continue } if (!entry.name.endsWith(".md")) continue const raw = readFileSync(full, "utf8") const lines = raw.split(/\r?\n/) if (lines[0]?.trim() !== "---") continue let end = -1 for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === "---") { end = i; break } } if (end === -1) continue const yaml = lines.slice(1, end).join("\n") const rel = path.relative(pluginRoot, full) results.push([rel, yaml]) } } walk(pluginRoot) return results } describe("frontmatter YAML validity", () => { const MAX_SKILL_DESCRIPTION_LENGTH = 1024 const pluginRoots = [ "plugins/compound-engineering", "plugins/coding-tutor", ] for (const pluginRoot of pluginRoots) { const root = path.join(process.cwd(), pluginRoot) try { statSync(root) } catch { continue } const files = collectFrontmatterFiles(root) for (const [rel, yaml] of files) { test(`${pluginRoot}/${rel} has valid strict YAML frontmatter`, () => { expect(() => load(yaml)).not.toThrow() }) test(`${pluginRoot}/${rel} description has no unwrapped angle-bracket tokens`, () => { const parsed = load(yaml) as Record | null const description = parsed && typeof parsed.description === "string" ? parsed.description : "" // Strip backtick-delimited spans; what remains must not contain a bare . // Cowork's plugin validator parses descriptions as HTML and rejects // unknown tags with a silent "Plugin validation failed" banner. See issue #602. const stripped = description.replace(/`[^`]*`/g, "") const bareTag = stripped.match(/<[A-Za-z][\w-]*>/) expect(bareTag, `Backtick-wrap or rephrase: ${bareTag?.[0] ?? ""}`).toBeNull() }) if (/^skills\/[^/]+\/SKILL\.md$/.test(rel)) { test(`${pluginRoot}/${rel} skill description fits 1024-char harness limit`, () => { const parsed = load(yaml) as Record | null const description = parsed && typeof parsed.description === "string" ? parsed.description : "" expect( [...description].length, `Shorten description to ${MAX_SKILL_DESCRIPTION_LENGTH} chars or less`, ).toBeLessThanOrEqual(MAX_SKILL_DESCRIPTION_LENGTH) }) // Pi rejects skill names that don't match the parent directory or contain // characters outside [a-z0-9-]. Upgrading from a pre-v3 install with // `name: ce:brainstorm` frontmatter in a renamed `ce-brainstorm` directory // triggered issue #449. Catch any reintroduction at the source. test(`${pluginRoot}/${rel} skill frontmatter name matches directory and uses valid characters`, () => { const parsed = load(yaml) as Record | null const name = parsed && typeof parsed.name === "string" ? parsed.name : "" const dirName = path.basename(path.dirname(rel)) expect(name, `frontmatter name must be present`).not.toBe("") expect(name, `frontmatter name "${name}" must match parent directory "${dirName}"`).toBe(dirName) expect(name, `frontmatter name "${name}" must be lowercase a-z, 0-9, and hyphens`).toMatch(/^[a-z0-9-]+$/) }) // All compound-engineering skills (and agents) must use the `ce-` prefix // so they are unambiguously identifiable as compound-engineering // components. See plugins/compound-engineering/AGENTS.md "Naming // Convention". A small allowlist preserves three pre-existing skills // that predate the rule -- no new entries should be added. if (pluginRoot === "plugins/compound-engineering") { const SKILL_PREFIX_ALLOWLIST = new Set([ "every-style-editor", "file-todos", "lfg", ]) test(`${pluginRoot}/${rel} skill name uses ce- prefix`, () => { const dirName = path.basename(path.dirname(rel)) if (SKILL_PREFIX_ALLOWLIST.has(dirName)) return expect( dirName.startsWith("ce-"), `Skill "${dirName}" must use the ce- prefix. ` + `If this is a legacy skill that predates the rule, add it to ` + `SKILL_PREFIX_ALLOWLIST in tests/frontmatter.test.ts.`, ).toBe(true) }) } } if ( pluginRoot === "plugins/compound-engineering" && /^agents\/[^/]+\.md$/.test(rel) ) { test(`${pluginRoot}/${rel} agent name uses ce- prefix`, () => { const fileName = path.basename(rel, ".md") expect( fileName.startsWith("ce-"), `Agent "${fileName}" must use the ce- prefix.`, ).toBe(true) }) // Pure document-reasoning reviewers operate on document text already // passed in their prompt and do not look at the codebase. Granting // Bash invites the model -- especially on weaker models like haiku -- // to externalize state via temp-file scratchpads, which can hang // indefinitely on platforms whose bash tool blocks on heredocs (see // issue #832 for the OpenCode coherence-reviewer stall). const NO_BASH_AGENTS = new Set([ "ce-coherence-reviewer", ]) const agentName = path.basename(rel, ".md") if (NO_BASH_AGENTS.has(agentName)) { test(`${pluginRoot}/${rel} pure document reviewer must not allow Bash`, () => { const parsed = load(yaml) as Record | null const tools = parsed && typeof parsed.tools === "string" ? parsed.tools : "" const toolList = tools.split(",").map((s) => s.trim()) expect( toolList.includes("Bash"), `Agent "${agentName}" reviews documents from prompt context only and does ` + `not need shell access. Remove Bash from the tools allowlist (see issue #832).`, ).toBe(false) }) } } } } })