diff --git a/packages/shared/src/frontmatter.test.ts b/packages/shared/src/frontmatter.test.ts new file mode 100644 index 00000000..fa5dda0a --- /dev/null +++ b/packages/shared/src/frontmatter.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatterMarkdown } from "./frontmatter.js"; + +describe("parseFrontmatterMarkdown", () => { + it("parses folded and literal YAML block scalars", () => { + const folded = parseFrontmatterMarkdown([ + "---", + "name: Folded", + "description: >", + " First line", + " second line", + "", + " Third paragraph", + "---", + "", + "Body", + ].join("\n")); + + expect(folded.frontmatter.description).toBe("First line second line\n\nThird paragraph\n"); + + const literal = parseFrontmatterMarkdown([ + "---", + "name: Literal", + "description: |", + " First line", + " second line", + "---", + "", + "Body", + ].join("\n")); + + expect(literal.frontmatter.description).toBe("First line\nsecond line\n"); + }); + + it("respects block-scalar chomping indicators", () => { + const foldedStrip = parseFrontmatterMarkdown([ + "---", + "description: >-", + " First line", + " second line", + "", + " Third paragraph", + "---", + "", + "Body", + ].join("\n")); + + expect(foldedStrip.frontmatter.description).toBe("First line second line\n\nThird paragraph"); + + const literalKeep = parseFrontmatterMarkdown([ + "---", + "description: |+", + " First line", + " second line", + "", + "", + "---", + "", + "Body", + ].join("\n")); + + expect(literalKeep.frontmatter.description).toBe("First line\nsecond line\n\n"); + }); + + it("parses inline object array items nested under frontmatter keys", () => { + const parsed = parseFrontmatterMarkdown([ + "---", + "metadata:", + " sources:", + " - kind: github-dir", + " repo: paperclipai/paperclip", + " path: skills/paperclip", + "---", + "", + "Body", + ].join("\n")); + + expect(parsed.frontmatter).toMatchObject({ + metadata: { + sources: [ + { + kind: "github-dir", + repo: "paperclipai/paperclip", + path: "skills/paperclip", + }, + ], + }, + }); + }); + + it("does not treat trailing-dot decimals as numbers", () => { + const parsed = parseFrontmatterMarkdown([ + "---", + "version: 1.", + "---", + "", + ].join("\n")); + + expect(parsed.frontmatter.version).toBe("1."); + }); +}); diff --git a/packages/shared/src/frontmatter.ts b/packages/shared/src/frontmatter.ts index a140f5c9..610bd1d5 100644 --- a/packages/shared/src/frontmatter.ts +++ b/packages/shared/src/frontmatter.ts @@ -42,7 +42,7 @@ export function parseFrontmatterMarkdown(raw: string): MarkdownDoc { return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false }; } - const frontmatterRaw = normalized.slice(4, closing).trim(); + const frontmatterRaw = normalized.slice(4, closing); const body = normalized.slice(closing + 5).trim(); return { frontmatter: parseYamlFrontmatter(frontmatterRaw), @@ -53,8 +53,9 @@ export function parseFrontmatterMarkdown(raw: string): MarkdownDoc { function parseYamlFrontmatter(raw: string): Record { const prepared = prepareYamlLines(raw); - if (prepared.length === 0) return {}; - const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); + const firstContentIndex = prepared.findIndex((line) => !line.isBlank && !line.isComment); + if (firstContentIndex < 0) return {}; + const parsed = parseYamlBlock(prepared, firstContentIndex, prepared[firstContentIndex]!.indent); return isPlainRecord(parsed.value) ? parsed.value : {}; } @@ -63,17 +64,22 @@ function prepareYamlLines(raw: string) { .split("\n") .map((line) => ({ indent: line.match(/^ */)?.[0].length ?? 0, + raw: line, content: line.trim(), - })) - .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); + isBlank: line.trim().length === 0, + isComment: line.trim().startsWith("#"), + })); } function parseYamlBlock( - lines: Array<{ indent: number; content: string }>, + lines: Array<{ indent: number; raw: string; content: string; isBlank: boolean; isComment: boolean }>, startIndex: number, indentLevel: number, ): { value: unknown; nextIndex: number } { let index = startIndex; + while (index < lines.length && (lines[index]!.isBlank || lines[index]!.isComment)) { + index += 1; + } if (index >= lines.length || lines[index]!.indent < indentLevel) { return { value: {}, nextIndex: index }; } @@ -83,6 +89,10 @@ function parseYamlBlock( const values: unknown[] = []; while (index < lines.length) { const line = lines[index]!; + if (line.isBlank || line.isComment) { + index += 1; + continue; + } if (line.indent < indentLevel) break; if (line.indent !== indentLevel || !line.content.startsWith("-")) break; @@ -95,6 +105,36 @@ function parseYamlBlock( continue; } + if (isYamlBlockScalarIndicator(remainder)) { + const block = parseYamlBlockScalar(lines, index, indentLevel, remainder); + values.push(block.value); + index = block.nextIndex; + continue; + } + + const inlineObjectSeparator = remainder.indexOf(":"); + if ( + inlineObjectSeparator > 0 + && !remainder.startsWith("\"") + && !remainder.startsWith("{") + && !remainder.startsWith("[") + ) { + const key = remainder.slice(0, inlineObjectSeparator).trim(); + const rawValue = remainder.slice(inlineObjectSeparator + 1).trim(); + const nextObject: Record = { + [key]: parseYamlScalar(rawValue), + }; + if (index < lines.length && lines[index]!.indent > indentLevel) { + const nested = parseYamlBlock(lines, index, indentLevel + 2); + if (isPlainRecord(nested.value)) { + Object.assign(nextObject, nested.value); + } + index = nested.nextIndex; + } + values.push(nextObject); + continue; + } + values.push(parseYamlScalar(remainder)); } return { value: values, nextIndex: index }; @@ -103,6 +143,10 @@ function parseYamlBlock( const record: Record = {}; while (index < lines.length) { const line = lines[index]!; + if (line.isBlank || line.isComment) { + index += 1; + continue; + } if (line.indent < indentLevel) break; if (line.indent !== indentLevel) { index += 1; @@ -124,12 +168,93 @@ function parseYamlBlock( index = nested.nextIndex; continue; } + if (isYamlBlockScalarIndicator(remainder)) { + const block = parseYamlBlockScalar(lines, index, indentLevel, remainder); + record[key] = block.value; + index = block.nextIndex; + continue; + } record[key] = parseYamlScalar(remainder); } return { value: record, nextIndex: index }; } +function isYamlBlockScalarIndicator(rawValue: string) { + return /^[>|][+-]?$/.test(rawValue.trim()); +} + +function parseYamlBlockScalar( + lines: Array<{ indent: number; raw: string; content: string; isBlank: boolean; isComment: boolean }>, + startIndex: number, + parentIndent: number, + indicator: string, +): { value: string; nextIndex: number } { + const trimmedIndicator = indicator.trim(); + const style = trimmedIndicator[0]; + const chomp = trimmedIndicator.endsWith("+") + ? "+" + : trimmedIndicator.endsWith("-") + ? "-" + : ""; + let index = startIndex; + const collected: Array<{ indent: number; raw: string; isBlank: boolean }> = []; + while (index < lines.length) { + const line = lines[index]!; + if (!line.isBlank && line.indent <= parentIndent) break; + collected.push({ indent: line.indent, raw: line.raw, isBlank: line.isBlank }); + index += 1; + } + + const contentLines = collected.filter((line) => !line.isBlank); + if (contentLines.length === 0) return { value: "", nextIndex: index }; + + const blockIndent = Math.min(...contentLines.map((line) => line.indent)); + const normalizedLines = collected.map((line) => ( + line.isBlank ? "" : line.raw.slice(Math.min(blockIndent, line.raw.length)) + )); + + const baseValue = style === "|" + ? normalizedLines.join("\n") + : foldYamlBlockScalarLines(normalizedLines); + + return { + value: applyYamlBlockChomp(baseValue, chomp), + nextIndex: index, + }; +} + +function foldYamlBlockScalarLines(lines: string[]) { + let value = ""; + let pendingBlankLines = 0; + for (const line of lines) { + if (line === "") { + pendingBlankLines += 1; + continue; + } + if (value.length === 0) { + value = `${"\n".repeat(pendingBlankLines)}${line}`; + } else if (pendingBlankLines > 0) { + value += `${"\n".repeat(pendingBlankLines + 1)}${line}`; + } else { + value += ` ${line}`; + } + pendingBlankLines = 0; + } + + if (pendingBlankLines > 0 && value.length > 0) { + value += "\n".repeat(pendingBlankLines); + } + return value; +} + +function applyYamlBlockChomp(value: string, chomp: "" | "+" | "-") { + if (chomp === "+") return value; + if (chomp === "-") return value.replace(/\n+$/u, ""); + if (value.length === 0) return value; + return value.replace(/\n+$/u, "") + "\n"; +} + function parseYamlScalar(rawValue: string): unknown { const trimmed = rawValue.trim(); if (trimmed === "") return ""; @@ -138,7 +263,7 @@ function parseYamlScalar(rawValue: string): unknown { if (trimmed === "false") return false; if (trimmed === "[]") return []; if (trimmed === "{}") return {}; - if (/^-?\d+(\.\d)?\d*$/.test(trimmed)) return Number(trimmed); + if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); if ( trimmed.startsWith("\"") || trimmed.startsWith("[") || diff --git a/packages/skills-catalog/package.json b/packages/skills-catalog/package.json index 640e14a4..79515879 100644 --- a/packages/skills-catalog/package.json +++ b/packages/skills-catalog/package.json @@ -46,6 +46,9 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "validate": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/validate-catalog.ts" }, + "dependencies": { + "@paperclipai/shared": "workspace:*" + }, "devDependencies": { "@types/node": "^24.6.0" } diff --git a/packages/skills-catalog/src/frontmatter.test.ts b/packages/skills-catalog/src/frontmatter.test.ts new file mode 100644 index 00000000..2166de9a --- /dev/null +++ b/packages/skills-catalog/src/frontmatter.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatterMarkdown } from "./frontmatter.js"; + +describe("skills catalog frontmatter parsing", () => { + it("supports YAML block scalars used by SKILL.md descriptions", () => { + const parsed = parseFrontmatterMarkdown([ + "---", + "name: Catalog Skill", + "description: >", + " First line", + " second line", + "---", + "", + "Body", + ].join("\n")); + + expect(parsed.frontmatter.description).toBe("First line second line\n"); + }); + + it("supports block-scalar chomping variants", () => { + const parsed = parseFrontmatterMarkdown([ + "---", + "name: Catalog Skill", + "description: >-", + " First line", + " second line", + "---", + "", + "Body", + ].join("\n")); + + expect(parsed.frontmatter.description).toBe("First line second line"); + }); +}); diff --git a/packages/skills-catalog/src/frontmatter.ts b/packages/skills-catalog/src/frontmatter.ts index e2e431e7..e4d9e8e1 100644 --- a/packages/skills-catalog/src/frontmatter.ts +++ b/packages/skills-catalog/src/frontmatter.ts @@ -1,154 +1,9 @@ -export interface MarkdownDoc { - frontmatter: Record; - body: string; - hasFrontmatter: boolean; -} +export { + asBoolean, + asString, + asStringArray, + parseFrontmatterMarkdown, + type MarkdownDoc, +} from "@paperclipai/shared"; -export function isPlainRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function asString(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -export function asBoolean(value: unknown): boolean | null { - return typeof value === "boolean" ? value : null; -} - -export function asStringArray(value: unknown): string[] | null { - if (value === undefined) return []; - if (!Array.isArray(value)) return null; - - const out: string[] = []; - for (const item of value) { - const text = asString(item); - if (!text) return null; - out.push(text); - } - return out; -} - -export function parseFrontmatterMarkdown(raw: string): MarkdownDoc { - const normalized = raw.replace(/\r\n/g, "\n"); - if (!normalized.startsWith("---\n")) { - return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false }; - } - - const closing = normalized.indexOf("\n---\n", 4); - if (closing < 0) { - return { frontmatter: {}, body: normalized.trim(), hasFrontmatter: false }; - } - - const frontmatterRaw = normalized.slice(4, closing).trim(); - const body = normalized.slice(closing + 5).trim(); - return { - frontmatter: parseYamlFrontmatter(frontmatterRaw), - body, - hasFrontmatter: true, - }; -} - -function parseYamlFrontmatter(raw: string): Record { - const prepared = prepareYamlLines(raw); - if (prepared.length === 0) return {}; - const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); - return isPlainRecord(parsed.value) ? parsed.value : {}; -} - -function prepareYamlLines(raw: string) { - return raw - .split("\n") - .map((line) => ({ - indent: line.match(/^ */)?.[0].length ?? 0, - content: line.trim(), - })) - .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); -} - -function parseYamlBlock( - lines: Array<{ indent: number; content: string }>, - startIndex: number, - indentLevel: number, -): { value: unknown; nextIndex: number } { - let index = startIndex; - if (index >= lines.length || lines[index]!.indent < indentLevel) { - return { value: {}, nextIndex: index }; - } - - const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-"); - if (isArray) { - const values: unknown[] = []; - while (index < lines.length) { - const line = lines[index]!; - if (line.indent < indentLevel) break; - if (line.indent !== indentLevel || !line.content.startsWith("-")) break; - - const remainder = line.content.slice(1).trim(); - index += 1; - if (!remainder) { - const nested = parseYamlBlock(lines, index, indentLevel + 2); - values.push(nested.value); - index = nested.nextIndex; - continue; - } - - values.push(parseYamlScalar(remainder)); - } - return { value: values, nextIndex: index }; - } - - const record: Record = {}; - while (index < lines.length) { - const line = lines[index]!; - if (line.indent < indentLevel) break; - if (line.indent !== indentLevel) { - index += 1; - continue; - } - - const separatorIndex = line.content.indexOf(":"); - if (separatorIndex <= 0) { - index += 1; - continue; - } - - const key = line.content.slice(0, separatorIndex).trim(); - const remainder = line.content.slice(separatorIndex + 1).trim(); - index += 1; - if (!remainder) { - const nested = parseYamlBlock(lines, index, indentLevel + 2); - record[key] = nested.value; - index = nested.nextIndex; - continue; - } - record[key] = parseYamlScalar(remainder); - } - - return { value: record, nextIndex: index }; -} - -function parseYamlScalar(rawValue: string): unknown { - const trimmed = rawValue.trim(); - if (trimmed === "") return ""; - if (trimmed === "null" || trimmed === "~") return null; - if (trimmed === "true") return true; - if (trimmed === "false") return false; - if (trimmed === "[]") return []; - if (trimmed === "{}") return {}; - if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); - if ( - trimmed.startsWith("\"") || - trimmed.startsWith("[") || - trimmed.startsWith("{") - ) { - try { - return JSON.parse(trimmed); - } catch { - return trimmed; - } - } - return trimmed; -} +export { isFrontmatterPlainRecord as isPlainRecord } from "@paperclipai/shared"; diff --git a/server/src/__tests__/company-skills.test.ts b/server/src/__tests__/company-skills.test.ts index bcc173d8..2a5c603f 100644 --- a/server/src/__tests__/company-skills.test.ts +++ b/server/src/__tests__/company-skills.test.ts @@ -185,6 +185,126 @@ describe("project workspace skill discovery", () => { ], }); }); + + it("parses folded and literal block scalar descriptions in skill frontmatter", async () => { + const foldedWorkspace = await makeTempDir("paperclip-folded-skill-yaml-"); + await fs.mkdir(foldedWorkspace, { recursive: true }); + await fs.writeFile( + path.join(foldedWorkspace, "SKILL.md"), + [ + "---", + "name: Folded Metadata Skill", + "description: >", + " Use when you need website engagement data - sessions,", + " pageviews, and conversions.", + "", + "---", + "", + "# Folded Metadata Skill", + "", + ].join("\n"), + "utf8", + ); + + const literalWorkspace = await makeTempDir("paperclip-literal-skill-yaml-"); + await fs.mkdir(literalWorkspace, { recursive: true }); + await fs.writeFile( + path.join(literalWorkspace, "SKILL.md"), + [ + "---", + "name: Literal Metadata Skill", + "description: |", + " First line.", + " Second line.", + "", + "metadata:", + " clipNote: |", + " Keep this line.", + " And this one.", + "", + " stripNote: |-", + " Strip this line.", + " And this one.", + "---", + "", + "# Literal Metadata Skill", + "", + ].join("\n"), + "utf8", + ); + + const stripWorkspace = await makeTempDir("paperclip-strip-skill-yaml-"); + await fs.mkdir(stripWorkspace, { recursive: true }); + await fs.writeFile( + path.join(stripWorkspace, "SKILL.md"), + [ + "---", + "name: Strip Metadata Skill", + "description: >-", + " Strip this folded description", + " without a trailing newline.", + "---", + "", + "# Strip Metadata Skill", + "", + ].join("\n"), + "utf8", + ); + + const folded = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + foldedWorkspace, + { inventoryMode: "full" }, + ); + const literal = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + literalWorkspace, + { inventoryMode: "full" }, + ); + const strip = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + stripWorkspace, + { inventoryMode: "full" }, + ); + + expect(folded.description).toBe( + "Use when you need website engagement data - sessions, pageviews, and conversions.", + ); + expect(literal.description).toBe("First line.\nSecond line."); + expect(literal.metadata).toMatchObject({ + clipNote: "Keep this line.\nAnd this one.\n", + stripNote: "Strip this line.\nAnd this one.", + }); + expect(strip.description).toBe("Strip this folded description without a trailing newline."); + }); + + it("parses YAML block-scalar chomping variants from SKILL.md frontmatter", async () => { + const workspace = await makeTempDir("paperclip-block-scalar-chomp-skill-"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile( + path.join(workspace, "SKILL.md"), + [ + "---", + "name: Block Scalar Chomp Skill", + "description: >-", + " First line", + " second line", + "---", + "", + "# Block Scalar Chomp Skill", + "", + ].join("\n"), + "utf8", + ); + + const imported = await readLocalSkillImportFromDirectory( + "33333333-3333-4333-8333-333333333333", + workspace, + { inventoryMode: "full" }, + ); + + expect(imported.description).toBe("First line second line"); + }); }); describe("missing local skill reconciliation", () => { diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 17c39f50..db14275b 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -45,7 +45,7 @@ import type { CompanySkillVersionCreateRequest, CompanySkillVersionFileInventoryEntry, } from "@paperclipai/shared"; -import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import { normalizeAgentUrlKey, parseFrontmatterMarkdown } from "@paperclipai/shared"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { conflict, notFound, unprocessable } from "../errors.js"; import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; @@ -565,139 +565,6 @@ function deriveTrustLevel(fileInventory: CompanySkillFileInventoryEntry[]): Comp return "markdown_only"; } -function prepareYamlLines(raw: string) { - return raw - .split("\n") - .map((line) => ({ - indent: line.match(/^ */)?.[0].length ?? 0, - content: line.trim(), - })) - .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); -} - -function parseYamlScalar(rawValue: string): unknown { - const trimmed = rawValue.trim(); - if (trimmed === "") return ""; - if (trimmed === "null" || trimmed === "~") return null; - if (trimmed === "true") return true; - if (trimmed === "false") return false; - if (trimmed === "[]") return []; - if (trimmed === "{}") return {}; - if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed); - if (trimmed.startsWith("\"") || trimmed.startsWith("[") || trimmed.startsWith("{")) { - try { - return JSON.parse(trimmed); - } catch { - return trimmed; - } - } - return trimmed; -} - -function parseYamlBlock( - lines: Array<{ indent: number; content: string }>, - startIndex: number, - indentLevel: number, -): { value: unknown; nextIndex: number } { - let index = startIndex; - while (index < lines.length && lines[index]!.content.length === 0) index += 1; - if (index >= lines.length || lines[index]!.indent < indentLevel) { - return { value: {}, nextIndex: index }; - } - - const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-"); - if (isArray) { - const values: unknown[] = []; - while (index < lines.length) { - const line = lines[index]!; - if (line.indent < indentLevel) break; - if (line.indent !== indentLevel || !line.content.startsWith("-")) break; - const remainder = line.content.slice(1).trim(); - index += 1; - if (!remainder) { - const nested = parseYamlBlock(lines, index, indentLevel + 2); - values.push(nested.value); - index = nested.nextIndex; - continue; - } - const inlineObjectSeparator = remainder.indexOf(":"); - if ( - inlineObjectSeparator > 0 && - !remainder.startsWith("\"") && - !remainder.startsWith("{") && - !remainder.startsWith("[") - ) { - const key = remainder.slice(0, inlineObjectSeparator).trim(); - const rawValue = remainder.slice(inlineObjectSeparator + 1).trim(); - const nextObject: Record = { - [key]: parseYamlScalar(rawValue), - }; - if (index < lines.length && lines[index]!.indent > indentLevel) { - const nested = parseYamlBlock(lines, index, indentLevel + 2); - if (isPlainRecord(nested.value)) { - Object.assign(nextObject, nested.value); - } - index = nested.nextIndex; - } - values.push(nextObject); - continue; - } - values.push(parseYamlScalar(remainder)); - } - return { value: values, nextIndex: index }; - } - - const record: Record = {}; - while (index < lines.length) { - const line = lines[index]!; - if (line.indent < indentLevel) break; - if (line.indent !== indentLevel) { - index += 1; - continue; - } - const separatorIndex = line.content.indexOf(":"); - if (separatorIndex <= 0) { - index += 1; - continue; - } - const key = line.content.slice(0, separatorIndex).trim(); - const remainder = line.content.slice(separatorIndex + 1).trim(); - index += 1; - if (!remainder) { - const nested = parseYamlBlock(lines, index, indentLevel + 2); - record[key] = nested.value; - index = nested.nextIndex; - continue; - } - record[key] = parseYamlScalar(remainder); - } - return { value: record, nextIndex: index }; -} - -function parseYamlFrontmatter(raw: string): Record { - const prepared = prepareYamlLines(raw); - if (prepared.length === 0) return {}; - const parsed = parseYamlBlock(prepared, 0, prepared[0]!.indent); - return isPlainRecord(parsed.value) ? parsed.value : {}; -} - -function parseFrontmatterMarkdown(raw: string): { frontmatter: Record; body: string } { - const normalized = raw.replace(/\r\n/g, "\n"); - if (!normalized.startsWith("---\n")) { - return { frontmatter: {}, body: normalized.trim() }; - } - const closing = normalized.indexOf("\n---\n", 4); - if (closing < 0) { - return { frontmatter: {}, body: normalized.trim() }; - } - const frontmatterRaw = normalized.slice(4, closing).trim(); - const body = normalized.slice(closing + 5).trim(); - return { - frontmatter: parseYamlFrontmatter(frontmatterRaw), - body, - }; -} - async function fetchText(url: string) { const response = await ghFetch(url); if (!response.ok) { diff --git a/ui/src/lib/company-skill-summary.test.ts b/ui/src/lib/company-skill-summary.test.ts new file mode 100644 index 00000000..c3b0c308 --- /dev/null +++ b/ui/src/lib/company-skill-summary.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveSkillSummaryText, sanitizeSkillSummaryText } from "./company-skill-summary"; + +describe("company skill summary text", () => { + it("drops stray YAML block scalar markers without rewriting other markdown", () => { + expect(sanitizeSkillSummaryText(">")).toBeNull(); + expect(sanitizeSkillSummaryText("|")).toBeNull(); + expect(sanitizeSkillSummaryText("- Helpful summary")).toBe("- Helpful summary"); + expect(sanitizeSkillSummaryText("# Helpful summary")).toBe("# Helpful summary"); + }); + + it("falls back to the skill key when requested and the summary is empty", () => { + expect(resolveSkillSummaryText({ + name: "Humanizer", + key: "content/humanizer", + description: ">", + }, { fallbackKey: true })).toBe("content/humanizer"); + + expect(resolveSkillSummaryText({ + name: "humanizer", + key: "humanizer", + description: "|", + }, { fallbackKey: true })).toBe("humanizer"); + }); + + it("falls back from a stale tagline to a real description", () => { + expect(resolveSkillSummaryText({ + tagline: ">", + description: "Cleans up rough AI prose.", + key: "content/humanizer", + name: "Humanizer", + })).toBe("Cleans up rough AI prose."); + }); +}); diff --git a/ui/src/lib/company-skill-summary.ts b/ui/src/lib/company-skill-summary.ts new file mode 100644 index 00000000..bbef9570 --- /dev/null +++ b/ui/src/lib/company-skill-summary.ts @@ -0,0 +1,31 @@ +type SkillSummaryInput = { + tagline?: string | null; + description?: string | null; + key?: string | null; + name?: string | null; +}; + +function isStaleYamlBlockScalarIndicator(raw: string) { + return /^[>|][+-]?$/.test(raw.trim()); +} + +export function sanitizeSkillSummaryText(raw: string | null | undefined): string | null { + const cleaned = (raw ?? "").trim(); + if (isStaleYamlBlockScalarIndicator(cleaned)) return null; + return cleaned.length > 0 ? cleaned : null; +} + +export function resolveSkillSummaryText( + skill: SkillSummaryInput, + options: { fallbackKey?: boolean } = {}, +): string | null { + const summary = sanitizeSkillSummaryText(skill.tagline) ?? sanitizeSkillSummaryText(skill.description); + if (summary) return summary; + + if (options.fallbackKey) { + const fallbackKey = skill.key?.trim(); + if (fallbackKey) return fallbackKey; + } + + return null; +} diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index e5ad9637..ba373445 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -22,6 +22,7 @@ import { useCompany } from "../context/CompanyContext"; import { useToastActions } from "../context/ToastContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; +import { resolveSkillSummaryText } from "../lib/company-skill-summary"; import { AgentConfigForm } from "../components/AgentConfigForm"; import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; @@ -2781,6 +2782,7 @@ export function AgentSkillsTab({ const renderSkillRow = (skill: SkillRow) => { const adapterEntry = skill.adapterEntry ?? adapterEntryByKey.get(skill.key); const required = Boolean(adapterEntry?.required); + const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true }); const rowClassName = cn( "flex items-start gap-3 border-b border-border px-3 py-3 text-sm last:border-b-0", skill.readOnly ? "bg-muted/20" : "hover:bg-accent/20", @@ -2800,9 +2802,9 @@ export function AgentSkillsTab({ ) : null} - {skill.description && ( + {summaryText && ( - {skill.description} + {summaryText} )} {skill.readOnly && skill.originLabel && ( diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index 95e6e76c..1542db74 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -62,6 +62,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { buildLineDiff, type DiffRow } from "../lib/line-diff"; import { cn, relativeTime } from "../lib/utils"; +import { resolveSkillSummaryText } from "../lib/company-skill-summary"; import { parseSkillRoute, skillRoute, @@ -496,18 +497,6 @@ function formatBytes(bytes: number) { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -// Some bundled skills ship with a frontmatter-only SKILL.md whose extracted -// description is just punctuation (e.g. ">"). Strip leading markdown syntax and -// fall back to a neutral placeholder so cards don't render bare glyphs. -function cardDescriptionText(raw: string | null | undefined): string { - const cleaned = (raw ?? "") - .replace(/^[\s>#*_\-`>]+/, "") - .trim(); - // Empty descriptions render as a blank line on cards so spacing stays - // consistent across the grid (PAP-10907). - return cleaned.length >= 3 ? cleaned : ""; -} - // --------------------------------------------------------------------------- // Skills Store discovery grid (PAP-10879) // --------------------------------------------------------------------------- @@ -536,6 +525,7 @@ export type DiscoveryCard = { slug: string; author: string; version: string | null; + tagline: string | null; description: string | null; categories: string[]; iconUrl: string | null; @@ -679,7 +669,8 @@ function buildDiscoveryCards( slug: skill.slug, author: skill.authorName ?? skill.sourceLabel ?? "you", version: discoveryVersionLabel(skill, required), - description: skill.tagline ?? skill.description, + tagline: skill.tagline ?? null, + description: skill.description ?? null, categories: uniqueCategories([...(skill.categories ?? []), catalogMatch?.category]), iconUrl: skill.iconUrl, color: skill.color, @@ -706,6 +697,7 @@ function buildDiscoveryCards( slug: entry.slug, author: entry.packageName ?? "Paperclip", version: discoveryVersionLabel({ packageVersion: entry.packageVersion ?? null, sourceRef: null }, required), + tagline: null, description: entry.description, categories: uniqueCategories([entry.category, ...(entry.tags ?? [])]), iconUrl: null, @@ -768,6 +760,7 @@ function discoveryMatchesSearch(card: DiscoveryCard, query: string): boolean { card.name, card.slug, card.author, + card.tagline ?? "", card.description ?? "", card.categories.join(" "), ].join(" ").toLowerCase(); @@ -830,7 +823,12 @@ function SkillCard({ card, onOpen }: { card: DiscoveryCard; onOpen: (card: Disco {/* Always reserve two lines so cards line up even without a description. */}

- {cardDescriptionText(card.description)} + {resolveSkillSummaryText({ + tagline: card.tagline, + description: card.description, + key: card.key, + name: card.name, + }) ?? ""}

@@ -1382,6 +1380,7 @@ function NewSkillWizard({ slug: effectiveSlug || "skill", author: "you", version: null, + tagline: draft.tagline || null, description: draft.tagline, categories: draft.categories, iconUrl: null, @@ -2673,9 +2672,7 @@ export function SkillDetailPage({ const currentPin = shortRef(skill.sourceRef); const latestPin = shortRef(updateStatus?.latestRef); const selectedVersion = versions.find((version) => version.id === currentVersionSelection(skill)) ?? null; - const subtitleText = skill.tagline || skill.description - ? cardDescriptionText(skill.tagline ?? skill.description) - : source.label; + const subtitleText = resolveSkillSummaryText(skill) ?? source.label; // Look up the richer agent record (icon, paused) for agents using this skill. const attachAgentMetaById = new Map(attachAgents.map((agent) => [agent.id, agent])); @@ -2958,6 +2955,7 @@ export function SkillDetailPage({ slug: detail.slug, author: detail.authorName ?? source.label, version: null, + tagline: detail.tagline, description: detail.description, categories: detail.categories, iconUrl: detail.iconUrl, diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index a786defc..299c72d4 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -8,6 +8,7 @@ import { companySkillsApi } from "../api/companySkills"; import { issuesApi } from "../api/issues"; import { projectsApi } from "../api/projects"; import { queryKeys } from "../lib/queryKeys"; +import { resolveSkillSummaryText } from "../lib/company-skill-summary"; import { AGENT_ROLES, type AdapterEnvironmentTestResult, type AgentPermissions } from "@paperclipai/shared"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; @@ -321,6 +322,7 @@ export function NewAgent() { {availableSkills.map((skill) => { const inputId = `skill-${skill.id}`; const checked = selectedSkillKeys.includes(skill.key); + const summaryText = resolveSkillSummaryText(skill, { fallbackKey: true }); return (
); diff --git a/ui/storybook/stories/skills-store-discovery.stories.tsx b/ui/storybook/stories/skills-store-discovery.stories.tsx index eb13b82b..32bab79b 100644 --- a/ui/storybook/stories/skills-store-discovery.stories.tsx +++ b/ui/storybook/stories/skills-store-discovery.stories.tsx @@ -14,6 +14,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "paperclip-dev", author: "Paperclip", version: "v2.4.1", + tagline: "Run and repair local Paperclip workspaces.", description: "Develop and operate a local Paperclip instance.", categories: ["devops", "coding"], iconUrl: null, @@ -35,6 +36,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "agent-browser", author: "here.now", version: "v0.8.2", + tagline: "Drive browsers from an agent loop.", description: "Browser automation CLI for AI agents.", categories: ["research", "browsers"], iconUrl: null, @@ -56,6 +58,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "verify", author: "Paperclip", version: "v1.0.3", + tagline: "Prove the change works in a real app run.", description: "Verify a code change works by running the app.", categories: ["testing"], iconUrl: null, @@ -77,6 +80,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "hue-prosumer", author: "you", version: "v0.1.0", + tagline: "Remix a design-language skill for production use.", description: "Generate design language skills, prosumer remix.", categories: ["design"], iconUrl: null, @@ -98,6 +102,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "claude-api", author: "Anthropic", version: "v1.6.0", + tagline: "Ship and tune apps on the Claude API.", description: "Build, debug and optimize Claude API apps.", categories: ["coding", "ai"], iconUrl: null, @@ -119,6 +124,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "security-review", author: "Paperclip", version: "v1.1.0", + tagline: "Review a branch for concrete security risks.", description: "Security review of pending changes on a branch.", categories: ["security"], iconUrl: null, @@ -140,6 +146,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "commit-perfect", author: "Yuki", version: "v3.0.1", + tagline: "Tighten commits and PR metadata before review.", description: "Polish commits and PR titles before review.", categories: ["git", "workflow"], iconUrl: null, @@ -161,6 +168,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "deep-research", author: "Astra", version: "v2.0.0", + tagline: "Synthesize multiple sources with citations.", description: "Multi-source research with citation-grade synthesis.", categories: ["research"], iconUrl: null, @@ -182,6 +190,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "paperclip", author: "Paperclip", version: "core", + tagline: "Coordinate company work through the control plane.", description: "Control plane API for tasks, routines, and coordination.", categories: ["core"], iconUrl: null, @@ -203,6 +212,7 @@ const MOCK_CARDS: DiscoveryCard[] = [ slug: "diagnose-why-work-stopped", author: "Paperclip", version: "core", + tagline: "Find the exact stop-point in stalled work.", description: "Forensics for stalled or looping work trees.", categories: ["workflow"], iconUrl: null,