fix: parse YAML block scalar skill descriptions (#5046)
## Thinking Path > - Paperclip is the open source control plane teams use to manage AI agents for work. > - Company skills are imported from `SKILL.md` files and rely on YAML frontmatter to describe what each skill does. > - Multi-line descriptions commonly use YAML block scalars (`>` and `|`), but the broken parser path behind #4989 reduced those descriptions to a literal `>` or `|`. > - The earliest contributor fix for that bug was PR #5046, so this branch keeps that PR as the canonical merge target instead of replacing it. > - Follow-up work from #5071 and #8258 was then transplanted onto this earlier branch so the final PR preserves contributor credit while still shipping the strongest complete fix. > - The resulting change fixes block-scalar parsing in the shared frontmatter path, aligns server company-skill imports with that shared parser, and prevents already-stale stored markers from rendering as junk in the UI. ## Linked Issues or Issue Description - Fixes #4989 - Refs #2863 - Refs #788 - Related superseded PRs: #5071, #8258 ## What Changed - Kept the original PR #5046 server-side company-skill fix and regression coverage as the base branch history. - Added the missing YAML chomping and indicator hardening explored further in #5071. - Moved frontmatter parsing to the shared parser path so `packages/shared`, `packages/skills-catalog`, and server company-skill imports stay aligned. - Added UI summary sanitization and fallback handling so stale stored `>` / `|` values no longer render as visible junk in company-skill cards. - Added regression coverage for shared frontmatter parsing, skills-catalog parsing, company-skill imports, and stale-summary fallback behavior. ## Verification - Passed locally: `pnpm exec vitest run packages/shared/src/frontmatter.test.ts packages/skills-catalog/src/frontmatter.test.ts server/src/__tests__/company-skills.test.ts ui/src/lib/company-skill-summary.test.ts` - Passed locally: `pnpm --filter @paperclipai/shared typecheck` - Passed locally: `pnpm --filter @paperclipai/skills-catalog typecheck` - Not fully runnable in this worktree: `pnpm --filter @paperclipai/server typecheck` currently fails in `packages/plugins/sdk` before reaching server code because local workspace `node_modules` type deps are missing (`TS2688` for `node` / `react`). - GitHub Actions / PR checks are rerunning on PR #5046 head `005290b7557725abf748d00f36dd24ea0d919aba`. ## Risks - Medium-low risk: the fix now touches shared parser code, server company-skill imports, and UI fallback display rather than only the server import path. - The parser is still intentionally narrower than a full YAML implementation; this change focuses on block-scalar correctness and the stale-description rendering path relevant to #4989 / #2863. - This branch intentionally supersedes narrower overlapping work from #5071 and duplicate work from #8258 once the survivor PR is green. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex / GPT-5-based coding agent with local shell and code-editing tools enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Devin Foley <devin@paperclip.ing>
This commit is contained in:
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
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<string, unknown> = {
|
||||
[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<string, 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) {
|
||||
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("[") ||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -1,154 +1,9 @@
|
||||
export interface MarkdownDoc {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
hasFrontmatter: boolean;
|
||||
}
|
||||
export {
|
||||
asBoolean,
|
||||
asString,
|
||||
asStringArray,
|
||||
parseFrontmatterMarkdown,
|
||||
type MarkdownDoc,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
export function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> = {};
|
||||
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";
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown> = {
|
||||
[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<string, unknown> = {};
|
||||
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<string, unknown> {
|
||||
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<string, unknown>; 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) {
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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({
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{skill.description && (
|
||||
{summaryText && (
|
||||
<MarkdownBody className="mt-1 text-xs text-muted-foreground prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-li:my-0 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
|
||||
{skill.description}
|
||||
{summaryText}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
{skill.readOnly && skill.originLabel && (
|
||||
|
||||
@@ -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. */}
|
||||
<p className="mt-2 line-clamp-2 min-h-8 text-xs text-muted-foreground">
|
||||
{cardDescriptionText(card.description)}
|
||||
{resolveSkillSummaryText({
|
||||
tagline: card.tagline,
|
||||
description: card.description,
|
||||
key: card.key,
|
||||
name: card.name,
|
||||
}) ?? ""}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto pt-3">
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div key={skill.id} className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
@@ -330,9 +332,7 @@ export function NewAgent() {
|
||||
/>
|
||||
<label htmlFor={inputId} className="grid gap-1 leading-none">
|
||||
<span className="text-sm font-medium">{skill.name}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{skill.description ?? skill.key}
|
||||
</span>
|
||||
{summaryText ? <span className="text-xs text-muted-foreground">{summaryText}</span> : null}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user