From 35a1d81518d5e7375606029f33499cecd4a774a8 Mon Sep 17 00:00:00 2001 From: Sebastian Slight Date: Sat, 17 Jan 2026 13:59:09 -0500 Subject: [PATCH] fix: handle multi-line metadata blocks in HOOK.md frontmatter The frontmatter parser was using a simple line-by-line regex that only captured single-line key-value pairs. This meant multi-line metadata blocks (as used by bundled hooks) were not parsed correctly. Changes: - Add extractMultiLineValue() to handle indented continuation lines - Use JSON5 instead of JSON.parse() to support trailing commas - Add comprehensive test coverage for frontmatter parsing Fixes #1113 --- src/hooks/frontmatter.test.ts | 259 ++++++++++++++++++++++++++++++++++ src/hooks/frontmatter.ts | 89 +++++++++++- 2 files changed, 342 insertions(+), 6 deletions(-) create mode 100644 src/hooks/frontmatter.test.ts diff --git a/src/hooks/frontmatter.test.ts b/src/hooks/frontmatter.test.ts new file mode 100644 index 000000000..98ba9478b --- /dev/null +++ b/src/hooks/frontmatter.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js"; + +describe("parseFrontmatter", () => { + it("parses single-line key-value pairs", () => { + const content = `--- +name: test-hook +description: "A test hook" +homepage: https://example.com +--- + +# Test Hook +`; + const result = parseFrontmatter(content); + expect(result.name).toBe("test-hook"); + expect(result.description).toBe("A test hook"); + expect(result.homepage).toBe("https://example.com"); + }); + + it("handles missing frontmatter", () => { + const content = "# Just a markdown file"; + const result = parseFrontmatter(content); + expect(result).toEqual({}); + }); + + it("handles unclosed frontmatter", () => { + const content = `--- +name: broken +`; + const result = parseFrontmatter(content); + expect(result).toEqual({}); + }); + + it("parses multi-line metadata block with indented JSON", () => { + const content = `--- +name: session-memory +description: "Save session context" +metadata: + { + "clawdbot": { + "emoji": "💾", + "events": ["command:new"] + } + } +--- + +# Session Memory Hook +`; + const result = parseFrontmatter(content); + expect(result.name).toBe("session-memory"); + expect(result.description).toBe("Save session context"); + expect(result.metadata).toBeDefined(); + expect(typeof result.metadata).toBe("string"); + + // Verify the metadata is valid JSON + const parsed = JSON.parse(result.metadata as string); + expect(parsed.clawdbot.emoji).toBe("💾"); + expect(parsed.clawdbot.events).toEqual(["command:new"]); + }); + + it("parses multi-line metadata with complex nested structure", () => { + const content = `--- +name: command-logger +description: "Log all command events" +metadata: + { + "clawdbot": + { + "emoji": "📝", + "events": ["command"], + "requires": { "config": ["workspace.dir"] }, + "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled" }] + } + } +--- +`; + const result = parseFrontmatter(content); + expect(result.name).toBe("command-logger"); + expect(result.metadata).toBeDefined(); + + const parsed = JSON.parse(result.metadata as string); + expect(parsed.clawdbot.emoji).toBe("📝"); + expect(parsed.clawdbot.events).toEqual(["command"]); + expect(parsed.clawdbot.requires.config).toEqual(["workspace.dir"]); + expect(parsed.clawdbot.install[0].kind).toBe("bundled"); + }); + + it("handles single-line metadata (inline JSON)", () => { + const content = `--- +name: simple-hook +metadata: {"clawdbot": {"events": ["test"]}} +--- +`; + const result = parseFrontmatter(content); + expect(result.name).toBe("simple-hook"); + expect(result.metadata).toBe('{"clawdbot": {"events": ["test"]}}'); + }); + + it("handles mixed single-line and multi-line values", () => { + const content = `--- +name: mixed-hook +description: "A hook with mixed values" +homepage: https://example.com +metadata: + { + "clawdbot": { + "events": ["command:new"] + } + } +enabled: true +--- +`; + const result = parseFrontmatter(content); + expect(result.name).toBe("mixed-hook"); + expect(result.description).toBe("A hook with mixed values"); + expect(result.homepage).toBe("https://example.com"); + expect(result.metadata).toBeDefined(); + expect(result.enabled).toBe("true"); + }); + + it("strips surrounding quotes from values", () => { + const content = `--- +name: "quoted-name" +description: 'single-quoted' +--- +`; + const result = parseFrontmatter(content); + expect(result.name).toBe("quoted-name"); + expect(result.description).toBe("single-quoted"); + }); + + it("handles CRLF line endings", () => { + const content = "---\r\nname: test\r\ndescription: crlf\r\n---\r\n"; + const result = parseFrontmatter(content); + expect(result.name).toBe("test"); + expect(result.description).toBe("crlf"); + }); + + it("handles CR line endings", () => { + const content = "---\rname: test\rdescription: cr\r---\r"; + const result = parseFrontmatter(content); + expect(result.name).toBe("test"); + expect(result.description).toBe("cr"); + }); +}); + +describe("resolveClawdbotMetadata", () => { + it("extracts clawdbot metadata from parsed frontmatter", () => { + const frontmatter = { + name: "test-hook", + metadata: JSON.stringify({ + clawdbot: { + emoji: "🔥", + events: ["command:new", "command:reset"], + requires: { + config: ["workspace.dir"], + bins: ["git"], + }, + }, + }), + }; + + const result = resolveClawdbotMetadata(frontmatter); + expect(result).toBeDefined(); + expect(result?.emoji).toBe("🔥"); + expect(result?.events).toEqual(["command:new", "command:reset"]); + expect(result?.requires?.config).toEqual(["workspace.dir"]); + expect(result?.requires?.bins).toEqual(["git"]); + }); + + it("returns undefined when metadata is missing", () => { + const frontmatter = { name: "no-metadata" }; + const result = resolveClawdbotMetadata(frontmatter); + expect(result).toBeUndefined(); + }); + + it("returns undefined when clawdbot key is missing", () => { + const frontmatter = { + metadata: JSON.stringify({ other: "data" }), + }; + const result = resolveClawdbotMetadata(frontmatter); + expect(result).toBeUndefined(); + }); + + it("returns undefined for invalid JSON", () => { + const frontmatter = { + metadata: "not valid json {", + }; + const result = resolveClawdbotMetadata(frontmatter); + expect(result).toBeUndefined(); + }); + + it("handles install specs", () => { + const frontmatter = { + metadata: JSON.stringify({ + clawdbot: { + events: ["command"], + install: [ + { id: "bundled", kind: "bundled", label: "Bundled with Clawdbot" }, + { id: "npm", kind: "npm", package: "@clawdbot/hook" }, + ], + }, + }), + }; + + const result = resolveClawdbotMetadata(frontmatter); + expect(result?.install).toHaveLength(2); + expect(result?.install?.[0].kind).toBe("bundled"); + expect(result?.install?.[1].kind).toBe("npm"); + expect(result?.install?.[1].package).toBe("@clawdbot/hook"); + }); + + it("handles os restrictions", () => { + const frontmatter = { + metadata: JSON.stringify({ + clawdbot: { + events: ["command"], + os: ["darwin", "linux"], + }, + }), + }; + + const result = resolveClawdbotMetadata(frontmatter); + expect(result?.os).toEqual(["darwin", "linux"]); + }); + + it("parses real session-memory HOOK.md format", () => { + // This is the actual format used in the bundled hooks + const content = `--- +name: session-memory +description: "Save session context to memory when /new command is issued" +homepage: https://docs.clawd.bot/hooks#session-memory +metadata: + { + "clawdbot": + { + "emoji": "💾", + "events": ["command:new"], + "requires": { "config": ["workspace.dir"] }, + "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }], + }, + } +--- + +# Session Memory Hook +`; + + const frontmatter = parseFrontmatter(content); + expect(frontmatter.name).toBe("session-memory"); + expect(frontmatter.metadata).toBeDefined(); + + const clawdbot = resolveClawdbotMetadata(frontmatter); + expect(clawdbot).toBeDefined(); + expect(clawdbot?.emoji).toBe("💾"); + expect(clawdbot?.events).toEqual(["command:new"]); + expect(clawdbot?.requires?.config).toEqual(["workspace.dir"]); + expect(clawdbot?.install?.[0].kind).toBe("bundled"); + }); +}); diff --git a/src/hooks/frontmatter.ts b/src/hooks/frontmatter.ts index 26f3c0ffb..1c6525432 100644 --- a/src/hooks/frontmatter.ts +++ b/src/hooks/frontmatter.ts @@ -1,3 +1,4 @@ +import JSON5 from "json5"; import type { ClawdbotHookMetadata, HookEntry, @@ -16,6 +17,48 @@ function stripQuotes(value: string): string { return value; } +/** + * Extract a multi-line block value from frontmatter lines. + * Handles indented continuation lines (YAML-style multi-line values). + * + * @param lines - All lines in the frontmatter block + * @param startIndex - Index of the line containing the key + * @returns The combined multi-line value and the number of lines consumed + */ +function extractMultiLineValue( + lines: string[], + startIndex: number, +): { value: string; linesConsumed: number } { + const startLine = lines[startIndex]; + const match = startLine.match(/^([\w-]+):\s*(.*)$/); + if (!match) return { value: "", linesConsumed: 1 }; + + const inlineValue = match[2].trim(); + + // If there's a value on the same line, return it (single-line case) + if (inlineValue) { + return { value: inlineValue, linesConsumed: 1 }; + } + + // Multi-line case: collect indented continuation lines + const valueLines: string[] = []; + let i = startIndex + 1; + + while (i < lines.length) { + const line = lines[i]; + // Stop if we hit a non-indented line (new key or empty line without indent) + if (line.length > 0 && !line.startsWith(" ") && !line.startsWith("\t")) { + break; + } + valueLines.push(line); + i++; + } + + // Join and trim the multi-line value + const combined = valueLines.join("\n").trim(); + return { value: combined, linesConsumed: i - startIndex }; +} + export function parseFrontmatter(content: string): ParsedHookFrontmatter { const frontmatter: ParsedHookFrontmatter = {}; const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); @@ -23,14 +66,47 @@ export function parseFrontmatter(content: string): ParsedHookFrontmatter { const endIndex = normalized.indexOf("\n---", 3); if (endIndex === -1) return frontmatter; const block = normalized.slice(4, endIndex); - for (const line of block.split("\n")) { + const lines = block.split("\n"); + + let i = 0; + while (i < lines.length) { + const line = lines[i]; const match = line.match(/^([\w-]+):\s*(.*)$/); - if (!match) continue; + if (!match) { + i++; + continue; + } + const key = match[1]; - const value = stripQuotes(match[2].trim()); - if (!key || !value) continue; - frontmatter[key] = value; + const inlineValue = match[2].trim(); + + if (!key) { + i++; + continue; + } + + // Check if this is a multi-line value (no inline value and next line is indented) + if (!inlineValue && i + 1 < lines.length) { + const nextLine = lines[i + 1]; + if (nextLine.startsWith(" ") || nextLine.startsWith("\t")) { + // Multi-line value + const { value, linesConsumed } = extractMultiLineValue(lines, i); + if (value) { + frontmatter[key] = value; + } + i += linesConsumed; + continue; + } + } + + // Single-line value + const value = stripQuotes(inlineValue); + if (value) { + frontmatter[key] = value; + } + i++; } + return frontmatter; } @@ -96,7 +172,8 @@ export function resolveClawdbotMetadata( const raw = getFrontmatterValue(frontmatter, "metadata"); if (!raw) return undefined; try { - const parsed = JSON.parse(raw) as { clawdbot?: unknown }; + // Use JSON5 to handle trailing commas and other relaxed JSON syntax + const parsed = JSON5.parse(raw) as { clawdbot?: unknown }; if (!parsed || typeof parsed !== "object") return undefined; const clawdbot = (parsed as { clawdbot?: unknown }).clawdbot; if (!clawdbot || typeof clawdbot !== "object") return undefined;