diff --git a/packages/shared/src/frontmatter.ts b/packages/shared/src/frontmatter.ts new file mode 100644 index 00000000000..a140f5c9765 --- /dev/null +++ b/packages/shared/src/frontmatter.ts @@ -0,0 +1,154 @@ +export interface MarkdownDoc { + frontmatter: Record; + body: string; + hasFrontmatter: boolean; +} + +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)?\d*$/.test(trimmed)) return Number(trimmed); + if ( + trimmed.startsWith("\"") || + trimmed.startsWith("[") || + trimmed.startsWith("{") + ) { + try { + return JSON.parse(trimmed); + } catch { + return trimmed; + } + } + return trimmed; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9184f95872e..02b87e60ee2 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,12 @@ export { agentAdapterTypeSchema, optionalAgentAdapterTypeSchema } from "./adapter-type.js"; +export { + asBoolean, + asString, + asStringArray, + isPlainRecord as isFrontmatterPlainRecord, + parseFrontmatterMarkdown, + type MarkdownDoc, +} from "./frontmatter.js"; export { COMPANY_STATUSES, DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES, diff --git a/server/src/__tests__/teams-catalog-service.test.ts b/server/src/__tests__/teams-catalog-service.test.ts index c4328046f87..0aeb786787c 100644 --- a/server/src/__tests__/teams-catalog-service.test.ts +++ b/server/src/__tests__/teams-catalog-service.test.ts @@ -226,6 +226,16 @@ describe("teamsCatalogService", () => { expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); }); + it("does not install catalog skills when bundle import fails", async () => { + mockCompanyPortabilityService.importBundle.mockRejectedValueOnce(new Error("import failed")); + const svc = teamsCatalogService({} as any); + + await expect(svc.installCatalogTeam("company-1", "core-exec-team")).rejects.toThrow("import failed"); + + expect(mockCompanySkillService.installFromCatalog).not.toHaveBeenCalled(); + expect(mockCompanySkillService.importFromSource).not.toHaveBeenCalled(); + }); + it("injects safe claude_local adapter defaults for every bundled agent when no overrides are supplied", async () => { const svc = teamsCatalogService({} as any); @@ -239,6 +249,29 @@ describe("teamsCatalogService", () => { }); }); + it("uses the configured safe adapter default for bundled agents", async () => { + const previousDefault = process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE; + process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = "opencode_local"; + try { + const svc = teamsCatalogService({} as any); + + await svc.installCatalogTeam("company-1", "core-exec-team"); + + const [importInput] = mockCompanyPortabilityService.importBundle.mock.calls.at(-1)!; + expect(importInput.adapterOverrides).toEqual({ + ceo: { adapterType: "opencode_local" }, + cto: { adapterType: "opencode_local" }, + qa: { adapterType: "opencode_local" }, + }); + } finally { + if (previousDefault === undefined) { + delete process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE; + } else { + process.env.PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE = previousDefault; + } + } + }); + it("supplies safe adapter defaults for product-design and product-engineering installs", async () => { const svc = teamsCatalogService({} as any); diff --git a/server/src/services/teams-catalog.ts b/server/src/services/teams-catalog.ts index 70e598d25b2..20f105280f1 100644 --- a/server/src/services/teams-catalog.ts +++ b/server/src/services/teams-catalog.ts @@ -21,6 +21,7 @@ import type { CompanyPortabilitySource, } from "@paperclipai/shared"; import { normalizeAgentUrlKey } from "@paperclipai/shared"; +import { parseFrontmatterMarkdown } from "@paperclipai/shared/frontmatter"; import { conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { agentService } from "./agents.js"; import { companyPortabilityService } from "./company-portability.js"; @@ -509,116 +510,8 @@ function mergePlainRecords( return merged; } -interface MarkdownDoc { - frontmatter: Record; - body: string; -} - -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 parseYamlFrontmatter(raw: string): Record { - const lines = raw - .split("\n") - .map((line) => ({ - indent: line.match(/^ */)?.[0].length ?? 0, - content: line.trim(), - })) - .filter((line) => line.content.length > 0 && !line.content.startsWith("#")); - if (lines.length === 0) return {}; - const parsed = parseYamlBlock(lines, 0, lines[0]!.indent); - return isPlainRecord(parsed.value) ? parsed.value : {}; -} - -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) { - record[key] = parseYamlScalar(remainder); - continue; - } - const nested = parseYamlBlock(lines, index, indentLevel + 2); - record[key] = nested.value; - index = nested.nextIndex; - } - return { value: record, nextIndex: index }; -} - -function parseFrontmatterMarkdown(raw: string): MarkdownDoc { - 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() }; - } - return { - frontmatter: parseYamlFrontmatter(normalized.slice(4, closing).trim()), - body: normalized.slice(closing + 5).trim(), - }; +function parseYamlDocument(raw: string): Record { + return parseFrontmatterMarkdown(`---\n${raw.trim()}\n---\n`).frontmatter; } function renderSimpleMarkdown(frontmatter: Record, body: string) { @@ -784,7 +677,11 @@ async function readCatalogTeamSourceFiles(team: CatalogTeam): Promise { const merged: Record = { ...(callerOverrides ?? {}) }; for (const slug of agentSlugs) { if (merged[slug]) continue; - merged[slug] = { adapterType: DEFAULT_SAFE_CATALOG_ADAPTER_TYPE }; + merged[slug] = { adapterType: defaultAdapterType }; } return merged; } @@ -891,9 +789,9 @@ export function teamsCatalogService(db: Db) { const files = await readCatalogTeamSourceFiles(team); const existingExtension = typeof files[".paperclip.yaml"] === "string" - ? parseYamlFrontmatter(files[".paperclip.yaml"]) + ? parseYamlDocument(files[".paperclip.yaml"]) : {}; - const generatedExtension = parseYamlFrontmatter(await renderCatalogProvenanceYaml(team, targetManager)); + const generatedExtension = parseYamlDocument(await renderCatalogProvenanceYaml(team, targetManager)); files[".paperclip.yaml"] = renderYamlFile(mergePlainRecords(existingExtension, generatedExtension)); rewriteAgentCatalogSkillRefs(team, files); @@ -995,11 +893,13 @@ export function teamsCatalogService(db: Db) { throw unprocessable(`Catalog team source preparation failed: ${prepared.errors.join("; ")}`); } + const defaultAdapterType = defaultSafeCatalogAdapterType(); const importInput: CompanyPortabilityImport = { ...buildPortabilityInput(companyId, prepared.source, options), adapterOverrides: withSafeCatalogAdapterDefaults( prepared.team.agentSlugs, options.adapterOverrides, + defaultAdapterType, ), secretValues: options.secretValues, }; @@ -1018,10 +918,9 @@ export function teamsCatalogService(db: Db) { ...importPreview.warnings, ...(defaultedAdapterSlugs.length > 0 ? [ - `Catalog agents without explicit overrides (${defaultedAdapterSlugs.join(", ")}) default to ${DEFAULT_SAFE_CATALOG_ADAPTER_TYPE}. Pass adapterOverrides to use a different supported adapter.`, + `Catalog agents without explicit overrides (${defaultedAdapterSlugs.join(", ")}) default to ${defaultAdapterType}. Pass adapterOverrides or PAPERCLIP_TEAMS_CATALOG_DEFAULT_ADAPTER_TYPE to use a different supported adapter.`, ] : []), - ...await prepareSkillInstalls(companyId, prepared), ]; const result = await portability.importBundle( importInput, @@ -1031,6 +930,7 @@ export function teamsCatalogService(db: Db) { sourceCompanyId: companyId, }, ); + warnings.push(...await prepareSkillInstalls(companyId, prepared)); result.warnings.push(...warnings); await logCatalogEvent("company.team_catalog_installed", companyId, prepared.team, options.actor, { warningCount: result.warnings.length,