diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 3d13c99f8..09fa63f2e 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -6,6 +6,10 @@ - Added a response-body retry gate to `fetchWithRetry()` for deterministic provider failures that return retryable HTTP statuses. +### Fixed + +- Fixed YAML frontmatter parsing for skill descriptions that use common unquoted plain scalars containing `: `, preserving typed fields without logging a warning. + ## [16.2.7] - 2026-06-30 ### Added diff --git a/packages/utils/src/frontmatter.ts b/packages/utils/src/frontmatter.ts index 093bd9c88..061ba87a3 100644 --- a/packages/utils/src/frontmatter.ts +++ b/packages/utils/src/frontmatter.ts @@ -37,6 +37,31 @@ function normalizeKeys(obj: T): T { return (changed ? result : obj) as T; } +const PLAIN_SCALAR_KEY_VALUE = /^(\s*[A-Za-z_][\w-]*:\s+)(\S.*?)(\s*)$/; +const FLOW_OR_EXPLICIT_VALUE_START = new Set(['"', "'", "[", "{", "|", ">", "!", "&", "*", "#"]); + +function quoteAmbiguousPlainScalars(metadata: string): string | undefined { + let changed = false; + const lines = metadata.split("\n").map(line => { + const match = line.match(PLAIN_SCALAR_KEY_VALUE); + if (!match) return line; + const [, prefix, rawValue, suffix] = match; + const value = rawValue.trimEnd(); + if (!value.includes(": ")) return line; + if (FLOW_OR_EXPLICIT_VALUE_START.has(value[0])) return line; + changed = true; + return `${prefix}${JSON.stringify(value)}${suffix}`; + }); + return changed ? lines.join("\n") : undefined; +} + +function parseYamlRecord(metadata: string): Record | null { + const loaded = YAML.parse(metadata.replaceAll("\t", " ")); + if (loaded === null || loaded === undefined) return null; + if (typeof loaded !== "object" || Array.isArray(loaded)) return null; + return loaded as Record; +} + export class FrontmatterError extends Error { constructor( error: Error, @@ -100,10 +125,19 @@ export function parseFrontmatter( const body = normalized.slice(endIndex + 4).trim(); try { - // Replace tabs with spaces for YAML compatibility, use failsafe mode for robustness - const loaded = YAML.parse(metadata.replaceAll("\t", " ")) as Record | null; + const loaded = parseYamlRecord(metadata); return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body }; } catch (error) { + const quotedMetadata = quoteAmbiguousPlainScalars(metadata); + if (quotedMetadata) { + try { + const loaded = parseYamlRecord(quotedMetadata); + return { frontmatter: normalizeKeys({ ...frontmatter, ...loaded }), body }; + } catch { + // Fall through to the existing warning + simple key/value fallback. + } + } + const err = new FrontmatterError( error instanceof Error ? error : new Error(`YAML: ${error}`), loc ?? `Inline '${truncate(content, 64)}'`, diff --git a/packages/utils/test/frontmatter.test.ts b/packages/utils/test/frontmatter.test.ts new file mode 100644 index 000000000..a3adc4510 --- /dev/null +++ b/packages/utils/test/frontmatter.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "bun:test"; +import { parseFrontmatter } from "@oh-my-pi/pi-utils"; +import * as logger from "@oh-my-pi/pi-utils/logger"; + +describe("parseFrontmatter", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("accepts unquoted skill descriptions containing colon-space without warning", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const content = `--- +name: tool-prompt-optimization +description: Optimize tool prompts. Two halves: measure schema overlap; keep scar tissue. +enabled: true +--- +Skill body`; + + const result = parseFrontmatter(content, { source: "bad-skill/SKILL.md" }); + + expect(result.frontmatter).toEqual({ + name: "tool-prompt-optimization", + description: "Optimize tool prompts. Two halves: measure schema overlap; keep scar tissue.", + enabled: true, + }); + expect(result.body).toBe("Skill body"); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("still warns and falls back for unrecoverable malformed frontmatter", () => { + const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {}); + const content = `--- +invalid: [unclosed array +--- +Body content`; + + const result = parseFrontmatter(content, { source: "broken.md" }); + + expect(result.frontmatter).toEqual({ invalid: "[unclosed array" }); + expect(result.body).toBe("Body content"); + expect(warnSpy).toHaveBeenCalledWith( + "Failed to parse YAML frontmatter", + expect.objectContaining({ err: expect.stringContaining("broken.md") }), + ); + }); +});