From 255cfabf3daedbe207352cf716f63312203b568b Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 3 Jun 2026 17:25:11 +0000 Subject: [PATCH] PAP-10236 scaffold teams catalog package --- .../company-defaults/core-exec-team/TEAM.md | 27 + .../core-exec-team/agents/ceo/AGENTS.md | 11 + .../core-exec-team/agents/cto/AGENTS.md | 11 + .../projects/first-project/PROJECT.md | 8 + .../tasks/first-heartbeat/TASK.md | 9 + .../optional/content/content-machine/TEAM.md | 24 + .../agents/content-lead/AGENTS.md | 11 + .../skills/content-calendar/SKILL.md | 12 + .../tasks/weekly-content-review/TASK.md | 8 + packages/teams-catalog/generated/catalog.json | 188 ++++ packages/teams-catalog/package.json | 52 + .../scripts/build-catalog-manifest.ts | 13 + .../teams-catalog/scripts/validate-catalog.ts | 13 + .../teams-catalog/src/catalog-builder.test.ts | 284 ++++++ packages/teams-catalog/src/catalog-builder.ts | 957 ++++++++++++++++++ packages/teams-catalog/src/frontmatter.ts | 154 +++ packages/teams-catalog/src/index.ts | 41 + .../teams-catalog/src/shipped-catalog.test.ts | 89 ++ packages/teams-catalog/src/types.ts | 114 +++ packages/teams-catalog/tsconfig.json | 8 + packages/teams-catalog/vitest.config.ts | 8 + 21 files changed, 2042 insertions(+) create mode 100644 packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/TEAM.md create mode 100644 packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/ceo/AGENTS.md create mode 100644 packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/cto/AGENTS.md create mode 100644 packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/PROJECT.md create mode 100644 packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/tasks/first-heartbeat/TASK.md create mode 100644 packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md create mode 100644 packages/teams-catalog/catalog/optional/content/content-machine/agents/content-lead/AGENTS.md create mode 100644 packages/teams-catalog/catalog/optional/content/content-machine/skills/content-calendar/SKILL.md create mode 100644 packages/teams-catalog/catalog/optional/content/content-machine/tasks/weekly-content-review/TASK.md create mode 100644 packages/teams-catalog/generated/catalog.json create mode 100644 packages/teams-catalog/package.json create mode 100644 packages/teams-catalog/scripts/build-catalog-manifest.ts create mode 100644 packages/teams-catalog/scripts/validate-catalog.ts create mode 100644 packages/teams-catalog/src/catalog-builder.test.ts create mode 100644 packages/teams-catalog/src/catalog-builder.ts create mode 100644 packages/teams-catalog/src/frontmatter.ts create mode 100644 packages/teams-catalog/src/index.ts create mode 100644 packages/teams-catalog/src/shipped-catalog.test.ts create mode 100644 packages/teams-catalog/src/types.ts create mode 100644 packages/teams-catalog/tsconfig.json create mode 100644 packages/teams-catalog/vitest.config.ts diff --git a/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/TEAM.md b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/TEAM.md new file mode 100644 index 00000000000..803c1869426 --- /dev/null +++ b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/TEAM.md @@ -0,0 +1,27 @@ +--- +name: Core Exec Team +description: Minimal default leadership and engineering team for bootstrapping a Paperclip company with a CEO, CTO, starter project, and first recurring operating task. +schema: agentcompanies/v1 +slug: core-exec-team +category: company-defaults +key: paperclipai/bundled/company-defaults/core-exec-team +manager: agents/ceo/AGENTS.md +includes: + - agents/cto/AGENTS.md + - projects/first-project/PROJECT.md +defaultInstall: true +recommendedForCompanyTypes: + - startup + - software +tags: + - default + - executive + - engineering +requiredSkills: + - paperclipai/bundled/paperclip-operations/task-planning + - paperclipai/bundled/software-development/github-pr-workflow +--- + +# Core Exec Team + +This fixture keeps Phase B intentionally small while proving that a team entry can carry an org root, a reporting relationship, a project, and a recurring starter task. diff --git a/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/ceo/AGENTS.md b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/ceo/AGENTS.md new file mode 100644 index 00000000000..5e7cedf5d39 --- /dev/null +++ b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/ceo/AGENTS.md @@ -0,0 +1,11 @@ +--- +name: CEO +slug: ceo +title: Chief Executive Officer +role: ceo +reportsTo: null +skills: + - task-planning +--- + +You are responsible for company direction, priority setting, and board-level updates. diff --git a/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/cto/AGENTS.md b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/cto/AGENTS.md new file mode 100644 index 00000000000..e0627717167 --- /dev/null +++ b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/agents/cto/AGENTS.md @@ -0,0 +1,11 @@ +--- +name: CTO +slug: cto +title: Chief Technology Officer +role: engineering-manager +reportsTo: ceo +skills: + - github-pr-workflow +--- + +You manage technical execution, engineering task breakdown, implementation quality, and verification. diff --git a/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/PROJECT.md b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/PROJECT.md new file mode 100644 index 00000000000..fa8e6aa193c --- /dev/null +++ b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/PROJECT.md @@ -0,0 +1,8 @@ +--- +name: First Project +slug: first-project +description: Starter project for first useful company execution work. +owner: cto +--- + +Use this project to turn the company goal into the first implementation task. diff --git a/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/tasks/first-heartbeat/TASK.md b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/tasks/first-heartbeat/TASK.md new file mode 100644 index 00000000000..f36aed1b8bc --- /dev/null +++ b/packages/teams-catalog/catalog/bundled/company-defaults/core-exec-team/projects/first-project/tasks/first-heartbeat/TASK.md @@ -0,0 +1,9 @@ +--- +name: First Heartbeat Review +slug: first-heartbeat +assignee: ceo +project: first-project +recurring: true +--- + +Review current priorities, confirm the next useful task, and report what changed. diff --git a/packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md b/packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md new file mode 100644 index 00000000000..7ea13e4649b --- /dev/null +++ b/packages/teams-catalog/catalog/optional/content/content-machine/TEAM.md @@ -0,0 +1,24 @@ +--- +name: Content Machine +description: Optional content operations team with a lead agent, a recurring review task, and a vendored local content planning skill. +schema: agentcompanies/v1 +slug: content-machine +category: content +key: paperclipai/optional/content/content-machine +manager: agents/content-lead/AGENTS.md +includes: + - skills/content-calendar/SKILL.md + - tasks/weekly-content-review/TASK.md +defaultInstall: false +recommendedForCompanyTypes: + - agency + - marketing +tags: + - content + - marketing + - routines +--- + +# Content Machine + +This optional fixture proves local skill resolution and recurring task inventory without introducing external source risk. diff --git a/packages/teams-catalog/catalog/optional/content/content-machine/agents/content-lead/AGENTS.md b/packages/teams-catalog/catalog/optional/content/content-machine/agents/content-lead/AGENTS.md new file mode 100644 index 00000000000..ef15d8bec20 --- /dev/null +++ b/packages/teams-catalog/catalog/optional/content/content-machine/agents/content-lead/AGENTS.md @@ -0,0 +1,11 @@ +--- +name: Content Lead +slug: content-lead +title: Content Lead +role: content-strategist +reportsTo: null +skills: + - content-calendar +--- + +You plan content themes, keep the editorial calendar current, and turn company updates into publishable material. diff --git a/packages/teams-catalog/catalog/optional/content/content-machine/skills/content-calendar/SKILL.md b/packages/teams-catalog/catalog/optional/content/content-machine/skills/content-calendar/SKILL.md new file mode 100644 index 00000000000..90b14e32d51 --- /dev/null +++ b/packages/teams-catalog/catalog/optional/content/content-machine/skills/content-calendar/SKILL.md @@ -0,0 +1,12 @@ +--- +name: content-calendar +description: Plan a weekly editorial calendar by mapping company goals to publishable topics, owners, status, and verification notes. +slug: content-calendar +tags: + - content + - planning +--- + +# Content Calendar + +Use this skill when creating or maintaining a lightweight content calendar from company priorities. diff --git a/packages/teams-catalog/catalog/optional/content/content-machine/tasks/weekly-content-review/TASK.md b/packages/teams-catalog/catalog/optional/content/content-machine/tasks/weekly-content-review/TASK.md new file mode 100644 index 00000000000..9de0eee7512 --- /dev/null +++ b/packages/teams-catalog/catalog/optional/content/content-machine/tasks/weekly-content-review/TASK.md @@ -0,0 +1,8 @@ +--- +name: Weekly Content Review +slug: weekly-content-review +assignee: content-lead +recurring: true +--- + +Review the content calendar, select the next posts to draft, and identify any blocked publication work. diff --git a/packages/teams-catalog/generated/catalog.json b/packages/teams-catalog/generated/catalog.json new file mode 100644 index 00000000000..fa0b6fccf4c --- /dev/null +++ b/packages/teams-catalog/generated/catalog.json @@ -0,0 +1,188 @@ +{ + "schemaVersion": 1, + "packageName": "@paperclipai/teams-catalog", + "packageVersion": "0.1.0", + "generatedAt": "2026-06-03T17:24:44.400Z", + "teams": [ + { + "id": "paperclipai:bundled:company-defaults:core-exec-team", + "key": "paperclipai/bundled/company-defaults/core-exec-team", + "kind": "bundled", + "category": "company-defaults", + "slug": "core-exec-team", + "name": "Core Exec Team", + "description": "Minimal default leadership and engineering team for bootstrapping a Paperclip company with a CEO, CTO, starter project, and first recurring operating task.", + "path": "catalog/bundled/company-defaults/core-exec-team", + "entrypoint": "TEAM.md", + "schema": "agentcompanies/v1", + "defaultInstall": true, + "recommendedForCompanyTypes": [ + "startup", + "software" + ], + "tags": [ + "default", + "executive", + "engineering" + ], + "counts": { + "agents": 2, + "projects": 1, + "tasks": 0, + "routines": 1, + "localSkills": 0, + "catalogSkills": 2, + "externalSkillSources": 0 + }, + "rootAgentSlugs": [ + "ceo" + ], + "agentSlugs": [ + "ceo", + "cto" + ], + "projectSlugs": [ + "first-project" + ], + "requiredSkills": [ + { + "type": "catalog", + "ref": "github-pr-workflow", + "agentSlugs": [ + "cto" + ], + "resolved": true, + "catalogSkillId": "paperclipai:bundled:software-development:github-pr-workflow", + "catalogSkillKey": "paperclipai/bundled/software-development/github-pr-workflow" + }, + { + "type": "catalog", + "ref": "task-planning", + "agentSlugs": [ + "ceo" + ], + "resolved": true, + "catalogSkillId": "paperclipai:bundled:paperclip-operations:task-planning", + "catalogSkillKey": "paperclipai/bundled/paperclip-operations/task-planning" + } + ], + "envInputs": [], + "sourceRefs": [], + "files": [ + { + "path": "TEAM.md", + "kind": "team", + "sizeBytes": 881, + "sha256": "ba82aebfb880e664ecfbf25f507cde73421553f0c9163983075ae5b41b2f0a26" + }, + { + "path": "agents/ceo/AGENTS.md", + "kind": "agent", + "sizeBytes": 198, + "sha256": "b60338f0de7bf9d0cdfdfeb11e6dce0204850715bd1e18863c4f133c1cb0b785" + }, + { + "path": "agents/cto/AGENTS.md", + "kind": "agent", + "sizeBytes": 235, + "sha256": "3ebecad5b0e2948c67c3403469aa08d95f0f1fcd8222f9cbe8de00eea0a3b376" + }, + { + "path": "projects/first-project/PROJECT.md", + "kind": "project", + "sizeBytes": 208, + "sha256": "65333c0fbc992cb1a33418bfa9d8236215aa55a3a65bfc31ff597af54fadb28a" + }, + { + "path": "projects/first-project/tasks/first-heartbeat/TASK.md", + "kind": "task", + "sizeBytes": 195, + "sha256": "45e2a90a78f43c587260bca09daa586404de9cf29162e3b9b6843c6c71d66f25" + } + ], + "trustLevel": "markdown_only", + "compatibility": "compatible", + "contentHash": "sha256:ab9ca597e8cc4cde290e560c3d9a65f2cd06a81867cd64bd7efb2bf26c8d2b74" + }, + { + "id": "paperclipai:optional:content:content-machine", + "key": "paperclipai/optional/content/content-machine", + "kind": "optional", + "category": "content", + "slug": "content-machine", + "name": "Content Machine", + "description": "Optional content operations team with a lead agent, a recurring review task, and a vendored local content planning skill.", + "path": "catalog/optional/content/content-machine", + "entrypoint": "TEAM.md", + "schema": "agentcompanies/v1", + "defaultInstall": false, + "recommendedForCompanyTypes": [ + "agency", + "marketing" + ], + "tags": [ + "content", + "marketing", + "routines" + ], + "counts": { + "agents": 1, + "projects": 0, + "tasks": 0, + "routines": 1, + "localSkills": 1, + "catalogSkills": 0, + "externalSkillSources": 0 + }, + "rootAgentSlugs": [ + "content-lead" + ], + "agentSlugs": [ + "content-lead" + ], + "projectSlugs": [], + "requiredSkills": [ + { + "type": "local", + "ref": "content-calendar", + "agentSlugs": [ + "content-lead" + ], + "resolved": true, + "localPath": "skills/content-calendar/SKILL.md" + } + ], + "envInputs": [], + "sourceRefs": [], + "files": [ + { + "path": "TEAM.md", + "kind": "team", + "sizeBytes": 670, + "sha256": "07a02f4a7503db77867a664bc0d30000bef19790f46b494a0ca94162cd378019" + }, + { + "path": "agents/content-lead/AGENTS.md", + "kind": "agent", + "sizeBytes": 251, + "sha256": "6fc2071ff463b6981780d5b40b2c0784cb119900062f8dc40ed372e7b8415bf7" + }, + { + "path": "skills/content-calendar/SKILL.md", + "kind": "skill", + "sizeBytes": 340, + "sha256": "aa62a332fc3149d6c0d58ca589c6b3910a373d542f16f749ec68ef2f3d7f29e9" + }, + { + "path": "tasks/weekly-content-review/TASK.md", + "kind": "task", + "sizeBytes": 208, + "sha256": "b42faffd7ce3267bae5d04472bcba5a880c578a538ecc0339f8df90f352650d5" + } + ], + "trustLevel": "markdown_only", + "compatibility": "compatible", + "contentHash": "sha256:0127b5a93fc1efe8265a2720e4d18f34b9c9a7ba6dd54b2032c3eb2722af2e17" + } + ] +} diff --git a/packages/teams-catalog/package.json b/packages/teams-catalog/package.json new file mode 100644 index 00000000000..6a3080df4ca --- /dev/null +++ b/packages/teams-catalog/package.json @@ -0,0 +1,52 @@ +{ + "name": "@paperclipai/teams-catalog", + "version": "0.1.0", + "license": "MIT", + "homepage": "https://github.com/paperclipai/paperclip", + "bugs": { + "url": "https://github.com/paperclipai/paperclip/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/paperclipai/paperclip", + "directory": "packages/teams-catalog" + }, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./types": "./src/types.ts", + "./catalog.json": "./generated/catalog.json" + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + }, + "./types": { + "types": "./dist/src/types.d.ts", + "import": "./dist/src/types.js" + }, + "./catalog.json": "./dist/generated/catalog.json" + }, + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + }, + "files": [ + "catalog", + "dist", + "generated" + ], + "scripts": { + "build": "pnpm run build:manifest && tsc -p tsconfig.json", + "build:manifest": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/build-catalog-manifest.ts", + "clean": "rm -rf dist", + "test": "pnpm -w exec vitest run --root packages/teams-catalog --config vitest.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "validate": "node ../../cli/node_modules/tsx/dist/cli.mjs scripts/validate-catalog.ts" + }, + "dependencies": { + "@paperclipai/skills-catalog": "workspace:*" + } +} diff --git a/packages/teams-catalog/scripts/build-catalog-manifest.ts b/packages/teams-catalog/scripts/build-catalog-manifest.ts new file mode 100644 index 00000000000..e8e356b99d4 --- /dev/null +++ b/packages/teams-catalog/scripts/build-catalog-manifest.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { writeCatalogManifest } from "../src/catalog-builder.js"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const result = await writeCatalogManifest(packageDir); + +if (result.errors.length > 0) { + console.error(result.errors.join("\n")); + process.exit(1); +} + +console.log(`Wrote ${result.manifest.teams.length} teams to generated/catalog.json`); diff --git a/packages/teams-catalog/scripts/validate-catalog.ts b/packages/teams-catalog/scripts/validate-catalog.ts new file mode 100644 index 00000000000..5983b142322 --- /dev/null +++ b/packages/teams-catalog/scripts/validate-catalog.ts @@ -0,0 +1,13 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { validateCatalog } from "../src/catalog-builder.js"; + +const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const result = await validateCatalog(packageDir); + +if (result.errors.length > 0) { + console.error(result.errors.join("\n")); + process.exit(1); +} + +console.log(`Validated ${result.manifest.teams.length} teams.`); diff --git a/packages/teams-catalog/src/catalog-builder.test.ts b/packages/teams-catalog/src/catalog-builder.test.ts new file mode 100644 index 00000000000..87d742e5360 --- /dev/null +++ b/packages/teams-catalog/src/catalog-builder.test.ts @@ -0,0 +1,284 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + buildCatalogManifest, + formatCatalogManifest, + validateCatalog, +} from "./catalog-builder.js"; + +const tempDirs: string[] = []; +const catalogSkills = [ + { + id: "paperclipai:bundled:software-development:github-pr-workflow", + key: "paperclipai/bundled/software-development/github-pr-workflow", + slug: "github-pr-workflow", + }, + { + id: "paperclipai:bundled:paperclip-operations:task-planning", + key: "paperclipai/bundled/paperclip-operations/task-planning", + slug: "task-planning", + }, +]; + +describe("teams catalog manifest", () => { + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + }); + + it("builds stable manifest entries from catalog team directories", async () => { + const packageDir = await createCatalogPackage(); + await writeTeam(packageDir, "bundled", "software-development", "product-engineering", { + frontmatter: [ + "name: Product Engineering", + "description: Product engineering team for implementation and review work.", + "schema: agentcompanies/v1", + "key: paperclipai/bundled/software-development/product-engineering", + "manager: agents/cto/AGENTS.md", + "recommendedForCompanyTypes:", + " - software", + "tags:", + " - engineering", + ], + files: { + "agents/cto/AGENTS.md": [ + "---", + "name: CTO", + "slug: cto", + "skills:", + " - github-pr-workflow", + "---", + "", + "Lead engineering.", + ].join("\n"), + "projects/app/PROJECT.md": [ + "---", + "name: App", + "slug: app", + "owner: cto", + "---", + "", + "Build the app.", + ].join("\n"), + "projects/app/tasks/review/TASK.md": [ + "---", + "name: Review", + "slug: review", + "assignee: cto", + "project: app", + "recurring: true", + "---", + "", + "Review progress.", + ].join("\n"), + }, + }); + + const result = await buildCatalogManifest({ + packageDir, + generatedAt: "2026-06-03T00:00:00.000Z", + catalogSkills, + }); + + expect(result.errors).toEqual([]); + expect(result.manifest.teams).toHaveLength(1); + expect(result.manifest.teams[0]).toMatchObject({ + id: "paperclipai:bundled:software-development:product-engineering", + key: "paperclipai/bundled/software-development/product-engineering", + kind: "bundled", + category: "software-development", + slug: "product-engineering", + name: "Product Engineering", + schema: "agentcompanies/v1", + trustLevel: "markdown_only", + compatibility: "compatible", + recommendedForCompanyTypes: ["software"], + tags: ["engineering"], + counts: { + agents: 1, + projects: 1, + tasks: 0, + routines: 1, + localSkills: 0, + catalogSkills: 1, + externalSkillSources: 0, + }, + rootAgentSlugs: ["cto"], + agentSlugs: ["cto"], + projectSlugs: ["app"], + }); + expect(result.manifest.teams[0]!.requiredSkills).toEqual([ + expect.objectContaining({ + type: "catalog", + ref: "github-pr-workflow", + resolved: true, + catalogSkillKey: "paperclipai/bundled/software-development/github-pr-workflow", + agentSlugs: ["cto"], + }), + ]); + expect(result.manifest.teams[0]!.files.map((file) => file.path)).toEqual([ + "TEAM.md", + "agents/cto/AGENTS.md", + "projects/app/PROJECT.md", + "projects/app/tasks/review/TASK.md", + ]); + expect(result.manifest.teams[0]!.contentHash).toMatch(/^sha256:[a-f0-9]{64}$/); + }); + + it("reports frontmatter, directory, uniqueness, reference, and skill errors together", async () => { + const packageDir = await createCatalogPackage(); + await writeTeam(packageDir, "bundled", "Bad_Category", "duplicate", { + frontmatter: [ + "name: Duplicate", + "schema: agentcompanies/v1", + "key: paperclipai/bundled/software-development/other", + "manager: agents/missing/AGENTS.md", + "recommendedForCompanyTypes: software", + ], + files: { + "agents/lead/AGENTS.md": [ + "---", + "name: Lead", + "slug: lead", + "reportsTo: missing-manager", + "skills:", + " - missing-skill", + "---", + "", + "Lead.", + ].join("\n"), + "tasks/bad/TASK.md": [ + "---", + "name: Bad", + "slug: bad", + "assignee: missing-agent", + "project: missing-project", + "---", + "", + "Bad task.", + ].join("\n"), + }, + }); + await writeTeam(packageDir, "optional", "software-development", "duplicate", { + frontmatter: [ + "name: Duplicate Optional", + "description: Optional duplicate slug.", + "schema: agentcompanies/v1", + "manager: agents/lead/AGENTS.md", + ], + files: { + "agents/lead/AGENTS.md": "---\nname: Lead\nslug: lead\n---\n\nLead.\n", + }, + }); + await fs.mkdir(path.join(packageDir, "catalog", "bundled", "software-development", "missing-team"), { + recursive: true, + }); + await fs.mkdir(path.join(packageDir, "catalog", "misc"), { recursive: true }); + await fs.writeFile(path.join(packageDir, "catalog", "misc", "TEAM.md"), "# Misplaced\n", "utf8"); + + const result = await buildCatalogManifest({ + packageDir, + generatedAt: "2026-06-03T00:00:00.000Z", + catalogSkills, + }); + + expect(result.errors).toEqual( + expect.arrayContaining([ + expect.stringContaining("catalog/misc/TEAM.md is not under catalog////TEAM.md"), + expect.stringContaining("catalog/bundled/software-development/missing-team is missing TEAM.md"), + expect.stringContaining("has invalid category"), + expect.stringContaining("frontmatter must include description"), + expect.stringContaining("key must be paperclipai/bundled/Bad_Category/duplicate"), + expect.stringContaining("field recommendedForCompanyTypes must be an array of strings"), + expect.stringContaining("manager must resolve to an AGENTS.md file"), + expect.stringContaining("reportsTo references unknown agent slug"), + expect.stringContaining("skill reference \"missing-skill\" does not resolve"), + expect.stringContaining("assignee references unknown agent slug"), + expect.stringContaining("project references unknown project slug"), + expect.stringContaining("Duplicate catalog slug \"duplicate\""), + ]), + ); + }); + + it("detects stale generated manifests", async () => { + const packageDir = await createCatalogPackage(); + await writeTeam(packageDir, "bundled", "software-development", "review", { + frontmatter: [ + "name: Review", + "description: Review implementation work.", + "schema: agentcompanies/v1", + "manager: agents/reviewer/AGENTS.md", + ], + files: { + "agents/reviewer/AGENTS.md": "---\nname: Reviewer\nslug: reviewer\n---\n\nReview.\n", + }, + }); + await fs.mkdir(path.join(packageDir, "generated"), { recursive: true }); + await fs.writeFile( + path.join(packageDir, "generated", "catalog.json"), + formatCatalogManifest({ + schemaVersion: 1, + packageName: "@paperclipai/teams-catalog", + packageVersion: "0.1.0", + generatedAt: "2026-06-03T00:00:00.000Z", + teams: [], + }), + "utf8", + ); + + const expected = await buildCatalogManifest({ + packageDir, + generatedAt: "2026-06-03T00:00:00.000Z", + catalogSkills, + }); + await fs.writeFile( + path.join(packageDir, "generated", "catalog.json"), + formatCatalogManifest({ ...expected.manifest, teams: [] }), + "utf8", + ); + + const result = await validateCatalog(packageDir); + + expect(result.errors).toContain( + "generated/catalog.json is stale. Run pnpm --filter @paperclipai/teams-catalog build:manifest.", + ); + }); +}); + +async function createCatalogPackage() { + const packageDir = await fs.mkdtemp(path.join(os.tmpdir(), "teams-catalog-")); + tempDirs.push(packageDir); + await fs.mkdir(path.join(packageDir, "catalog", "bundled"), { recursive: true }); + await fs.mkdir(path.join(packageDir, "catalog", "optional"), { recursive: true }); + await fs.writeFile( + path.join(packageDir, "package.json"), + JSON.stringify({ version: "0.1.0" }), + "utf8", + ); + return packageDir; +} + +async function writeTeam( + packageDir: string, + kind: "bundled" | "optional", + category: string, + slug: string, + options: { + frontmatter: string[]; + files?: Record; + }, +) { + const teamDir = path.join(packageDir, "catalog", kind, category, slug); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, "TEAM.md"), + `---\n${options.frontmatter.join("\n")}\n---\n\nUse this team.\n`, + "utf8", + ); + for (const [relativePath, content] of Object.entries(options.files ?? {})) { + const filePath = path.join(teamDir, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, "utf8"); + } +} diff --git a/packages/teams-catalog/src/catalog-builder.ts b/packages/teams-catalog/src/catalog-builder.ts new file mode 100644 index 00000000000..ada5001921f --- /dev/null +++ b/packages/teams-catalog/src/catalog-builder.ts @@ -0,0 +1,957 @@ +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { + asBoolean, + asString, + asStringArray, + isPlainRecord, + parseFrontmatterMarkdown, +} from "./frontmatter.js"; +import type { + CatalogManifest, + CatalogTeam, + CatalogTeamEnvInputSummary, + CatalogTeamFile, + CatalogTeamFileKind, + CatalogTeamKind, + CatalogTeamSkillRequirement, + CatalogTeamSkillRequirementType, + CatalogTeamSourceRef, + CatalogTeamTrustLevel, +} from "./types.js"; + +const CATALOG_PACKAGE_NAME = "@paperclipai/teams-catalog"; +const CATALOG_SCHEMA_VERSION = 1; +const TEAM_ENTRYPOINT = "TEAM.md"; +const MAX_CATALOG_FILE_BYTES = 1024 * 1024; +const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const CATALOG_KINDS = new Set(["bundled", "optional"]); +const TEAM_SCHEMA = "agentcompanies/v1"; +const LOCAL_PATH_SOURCE_TYPES = new Set(["local_path"]); +const EXTERNAL_SOURCE_TYPES = new Set(["skills_sh", "github", "url", "agent_package"]); + +interface TeamCandidate { + kind: CatalogTeamKind; + category: string; + slug: string; + absolutePath: string; +} + +interface CatalogSkillSummary { + id: string; + key: string; + slug: string; +} + +interface BuildCatalogManifestOptions { + packageDir: string; + generatedAt?: string; + catalogSkills?: CatalogSkillSummary[]; +} + +interface BuildCatalogManifestResult { + manifest: CatalogManifest; + errors: string[]; +} + +interface ParsedTeamFile { + relativePath: string; + frontmatter: Record; + hasFrontmatter: boolean; +} + +interface TeamPackageGraph { + agents: ParsedTeamFile[]; + projects: ParsedTeamFile[]; + tasks: ParsedTeamFile[]; + skills: ParsedTeamFile[]; +} + +export function formatCatalogManifest(manifest: CatalogManifest): string { + return `${JSON.stringify(manifest, null, 2)}\n`; +} + +export async function buildExpectedCatalogManifest( + packageDir: string, +): Promise { + const existing = await readExistingManifest(packageDir); + const firstPass = await buildCatalogManifest({ + packageDir, + generatedAt: existing?.generatedAt ?? new Date().toISOString(), + }); + + if (existing && sameManifestExceptGeneratedAt(existing, firstPass.manifest)) { + return firstPass; + } + + return buildCatalogManifest({ + packageDir, + generatedAt: new Date().toISOString(), + }); +} + +export async function buildCatalogManifest( + options: BuildCatalogManifestOptions, +): Promise { + const packageDir = path.resolve(options.packageDir); + const packageJson = await readPackageJson(packageDir); + const errors: string[] = []; + const catalogSkills = options.catalogSkills ?? await loadCatalogSkills(packageDir, errors); + const candidates = await discoverTeamCandidates(packageDir, errors); + const teams: CatalogTeam[] = []; + + collectCandidateUniquenessErrors(candidates, errors); + + for (const candidate of candidates) { + const team = await buildCatalogTeam(packageDir, candidate, catalogSkills, errors); + if (team) teams.push(team); + } + + teams.sort((a, b) => a.id.localeCompare(b.id)); + collectUniquenessErrors(teams, errors); + + return { + manifest: { + schemaVersion: CATALOG_SCHEMA_VERSION, + packageName: CATALOG_PACKAGE_NAME, + packageVersion: packageJson.version, + generatedAt: options.generatedAt ?? new Date().toISOString(), + teams, + }, + errors, + }; +} + +export async function validateCatalog(packageDir: string): Promise { + const expected = await buildExpectedCatalogManifest(packageDir); + const generatedPath = path.join(packageDir, "generated", "catalog.json"); + const errors = [...expected.errors]; + + let generatedText: string | null = null; + try { + generatedText = await fs.readFile(generatedPath, "utf8"); + JSON.parse(generatedText); + } catch (error) { + errors.push(`generated/catalog.json is missing or invalid: ${errorMessage(error)}`); + } + + if (generatedText !== null) { + const expectedText = formatCatalogManifest(expected.manifest); + if (generatedText !== expectedText) { + errors.push("generated/catalog.json is stale. Run pnpm --filter @paperclipai/teams-catalog build:manifest."); + } + } + + return { + manifest: expected.manifest, + errors, + }; +} + +export async function writeCatalogManifest(packageDir: string) { + const result = await buildExpectedCatalogManifest(packageDir); + if (result.errors.length > 0) return result; + + const generatedDir = path.join(packageDir, "generated"); + await fs.mkdir(generatedDir, { recursive: true }); + await fs.writeFile(path.join(generatedDir, "catalog.json"), formatCatalogManifest(result.manifest), "utf8"); + return result; +} + +async function readPackageJson(packageDir: string) { + const packageJsonPath = path.join(packageDir, "package.json"); + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8")) as { version?: unknown }; + const version = asString(packageJson.version); + if (!version) throw new Error(`${packageJsonPath} must declare a package version.`); + return { version }; +} + +async function readExistingManifest(packageDir: string): Promise { + try { + return JSON.parse(await fs.readFile(path.join(packageDir, "generated", "catalog.json"), "utf8")) as CatalogManifest; + } catch { + return null; + } +} + +async function loadCatalogSkills(packageDir: string, errors: string[]): Promise { + try { + const catalogPackageName = "@paperclipai/skills-catalog"; + const catalog = await import(catalogPackageName) as { catalogSkills: CatalogSkillSummary[] }; + const skills = catalog.catalogSkills as CatalogSkillSummary[]; + return skills.map((skill) => ({ id: skill.id, key: skill.key, slug: skill.slug })); + } catch { + const siblingManifestPath = path.resolve(packageDir, "..", "skills-catalog", "generated", "catalog.json"); + try { + const manifest = JSON.parse(await fs.readFile(siblingManifestPath, "utf8")) as { skills?: CatalogSkillSummary[] }; + return (manifest.skills ?? []).map((skill) => ({ id: skill.id, key: skill.key, slug: skill.slug })); + } catch (error) { + errors.push(`Could not load @paperclipai/skills-catalog for skill requirement validation: ${errorMessage(error)}`); + return []; + } + } +} + +async function discoverTeamCandidates(packageDir: string, errors: string[]) { + const catalogDir = path.join(packageDir, "catalog"); + const candidates: TeamCandidate[] = []; + + if (!existsSync(catalogDir)) { + errors.push("catalog directory is missing."); + return candidates; + } + + await collectMisplacedTeamFiles(catalogDir, errors); + + for (const kind of ["bundled", "optional"] as const) { + const kindDir = path.join(catalogDir, kind); + if (!existsSync(kindDir)) continue; + + for (const categoryEntry of await sortedDirEntries(kindDir)) { + if (!categoryEntry.isDirectory()) continue; + const category = categoryEntry.name; + const categoryDir = path.join(kindDir, category); + + for (const slugEntry of await sortedDirEntries(categoryDir)) { + if (!slugEntry.isDirectory()) continue; + const slug = slugEntry.name; + const teamDir = path.join(categoryDir, slug); + if (!existsSync(path.join(teamDir, TEAM_ENTRYPOINT))) { + errors.push(`${relativePackagePath(packageDir, teamDir)} is missing TEAM.md.`); + continue; + } + candidates.push({ kind, category, slug, absolutePath: teamDir }); + } + } + } + + return candidates; +} + +async function collectMisplacedTeamFiles(catalogDir: string, errors: string[]) { + async function visit(dir: string) { + for (const entry of await sortedDirEntries(dir)) { + const absolutePath = path.join(dir, entry.name); + if (entry.isDirectory()) { + await visit(absolutePath); + continue; + } + if (entry.name !== TEAM_ENTRYPOINT) continue; + + const relativePath = toPosixPath(path.relative(catalogDir, absolutePath)); + const parts = relativePath.split("/"); + const kind = parts[0]; + if (parts.length !== 4 || !CATALOG_KINDS.has(kind as CatalogTeamKind)) { + errors.push(`catalog/${relativePath} is not under catalog////TEAM.md.`); + } + } + } + + await visit(catalogDir); +} + +async function buildCatalogTeam( + packageDir: string, + candidate: TeamCandidate, + catalogSkills: CatalogSkillSummary[], + errors: string[], +): Promise { + const prefix = relativePackagePath(packageDir, candidate.absolutePath); + validateSlug("category", candidate.category, prefix, errors); + validateSlug("slug", candidate.slug, prefix, errors); + + const id = `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`; + const key = `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`; + const teamMarkdownPath = path.join(candidate.absolutePath, TEAM_ENTRYPOINT); + const parsed = parseFrontmatterMarkdown(await fs.readFile(teamMarkdownPath, "utf8")); + + if (!parsed.hasFrontmatter) { + errors.push(`${prefix}/TEAM.md must start with YAML frontmatter.`); + } + + const name = asString(parsed.frontmatter.name); + if (!name) errors.push(`${prefix}/TEAM.md frontmatter must include name.`); + + const description = asString(parsed.frontmatter.description); + if (!description) errors.push(`${prefix}/TEAM.md frontmatter must include description.`); + + const schema = asString(parsed.frontmatter.schema); + if (schema !== TEAM_SCHEMA) { + errors.push(`${prefix}/TEAM.md schema must be ${TEAM_SCHEMA}.`); + } + + const explicitKey = asString(parsed.frontmatter.key); + if (explicitKey && explicitKey !== key) { + errors.push(`${prefix}/TEAM.md key must be ${key}.`); + } + + const explicitSlug = asString(parsed.frontmatter.slug); + if (explicitSlug && explicitSlug !== candidate.slug) { + errors.push(`${prefix}/TEAM.md slug must be ${candidate.slug}.`); + } + + const explicitCategory = asString(parsed.frontmatter.category); + if (explicitCategory && explicitCategory !== candidate.category) { + errors.push(`${prefix}/TEAM.md category must be ${candidate.category}.`); + } + + const defaultInstall = asBoolean(parsed.frontmatter.defaultInstall) ?? false; + const recommendedForCompanyTypes = readStringArrayField( + parsed.frontmatter.recommendedForCompanyTypes, + "recommendedForCompanyTypes", + prefix, + errors, + ); + const tags = readStringArrayField(parsed.frontmatter.tags, "tags", prefix, errors); + const files = await collectTeamFiles(packageDir, candidate.absolutePath, prefix, errors); + const graph = await readTeamPackageGraph(candidate.absolutePath, errors); + const agentSlugs = collectSlugs(graph.agents, "agent", errors); + const projectSlugs = collectSlugs(graph.projects, "project", errors); + const taskRecords = graph.tasks.map((task) => ({ + slug: readSlug(task, "task", errors), + recurring: asBoolean(task.frontmatter.recurring) ?? false, + assignee: asString(task.frontmatter.assignee), + project: asString(task.frontmatter.project), + path: task.relativePath, + })); + const localSkillSlugs = collectSlugs(graph.skills, "skill", errors); + const rootAgentSlugs = validateLocalReferences(candidate.absolutePath, parsed.frontmatter, graph, agentSlugs, projectSlugs, errors); + const requiredSkills = collectRequiredSkills(candidate.absolutePath, parsed.frontmatter, graph, catalogSkills, agentSlugs, localSkillSlugs, errors); + const envInputs = collectEnvInputs(graph); + const sourceRefs = collectSourceRefs(parsed.frontmatter, requiredSkills); + const catalogSkillCount = new Set(requiredSkills.filter((skill) => skill.type === "catalog").map((skill) => skill.catalogSkillId ?? skill.ref)).size; + + if (!name || !description || schema !== TEAM_SCHEMA) return null; + + return { + id, + key, + kind: candidate.kind, + category: candidate.category, + slug: candidate.slug, + name, + description, + path: toPosixPath(path.relative(packageDir, candidate.absolutePath)), + entrypoint: TEAM_ENTRYPOINT, + schema: TEAM_SCHEMA, + defaultInstall, + recommendedForCompanyTypes, + tags, + counts: { + agents: graph.agents.length, + projects: graph.projects.length, + tasks: taskRecords.filter((task) => !task.recurring).length, + routines: taskRecords.filter((task) => task.recurring).length, + localSkills: graph.skills.length, + catalogSkills: catalogSkillCount, + externalSkillSources: sourceRefs.filter((ref) => ref.type !== "include").length, + }, + rootAgentSlugs, + agentSlugs: agentSlugs.sort(), + projectSlugs: projectSlugs.sort(), + requiredSkills, + envInputs, + sourceRefs, + files, + trustLevel: deriveTrustLevel(files, sourceRefs), + compatibility: "compatible", + contentHash: buildContentHash(files), + }; +} + +async function collectTeamFiles( + packageDir: string, + teamDir: string, + prefix: string, + errors: string[], +): Promise { + const files: CatalogTeamFile[] = []; + const teamRoot = await fs.realpath(teamDir); + + async function visit(dir: string) { + for (const entry of await sortedDirEntries(dir)) { + const absolutePath = path.join(dir, entry.name); + const lstat = await fs.lstat(absolutePath); + let stat = lstat; + let realPath = absolutePath; + + if (lstat.isSymbolicLink()) { + try { + realPath = await fs.realpath(absolutePath); + stat = await fs.stat(absolutePath); + } catch { + errors.push(`${relativePackagePath(packageDir, absolutePath)} is a broken symlink.`); + continue; + } + if (!isPathInside(teamRoot, realPath)) { + errors.push(`${relativePackagePath(packageDir, absolutePath)} points outside its team directory.`); + continue; + } + if (stat.isDirectory()) { + errors.push(`${relativePackagePath(packageDir, absolutePath)} is a directory symlink; copy files into the team directory instead.`); + continue; + } + } + + if (stat.isDirectory()) { + await visit(absolutePath); + continue; + } + if (!stat.isFile()) continue; + + const relativePath = toPosixPath(path.relative(teamDir, absolutePath)); + if (path.isAbsolute(relativePath) || relativePath.split("/").includes("..")) { + errors.push(`${prefix}/${relativePath} has an invalid inventory path.`); + continue; + } + if (stat.size > MAX_CATALOG_FILE_BYTES) { + errors.push(`${prefix}/${relativePath} exceeds ${MAX_CATALOG_FILE_BYTES} bytes.`); + } + + const contents = await fs.readFile(absolutePath); + files.push({ + path: relativePath, + kind: classifyCatalogFile(relativePath), + sizeBytes: stat.size, + sha256: sha256(contents), + }); + } + } + + await visit(teamDir); + files.sort((a, b) => { + if (a.path === TEAM_ENTRYPOINT) return -1; + if (b.path === TEAM_ENTRYPOINT) return 1; + return a.path.localeCompare(b.path); + }); + + if (!files.some((file) => file.path === TEAM_ENTRYPOINT && file.kind === "team")) { + errors.push(`${prefix} inventory does not contain TEAM.md.`); + } + + return files; +} + +async function readTeamPackageGraph(teamDir: string, errors: string[]): Promise { + const graph: TeamPackageGraph = { + agents: [], + projects: [], + tasks: [], + skills: [], + }; + + async function visit(dir: string) { + for (const entry of await sortedDirEntries(dir)) { + const absolutePath = path.join(dir, entry.name); + const stat = await fs.lstat(absolutePath); + if (stat.isDirectory()) { + await visit(absolutePath); + continue; + } + if (!stat.isFile()) continue; + + const relativePath = toPosixPath(path.relative(teamDir, absolutePath)); + const bucket = graphBucketForFile(relativePath); + if (!bucket) continue; + const doc = parseFrontmatterMarkdown(await fs.readFile(absolutePath, "utf8")); + if (!doc.hasFrontmatter) errors.push(`${relativePath} must start with YAML frontmatter.`); + graph[bucket].push({ + relativePath, + frontmatter: doc.frontmatter, + hasFrontmatter: doc.hasFrontmatter, + }); + } + } + + await visit(teamDir); + return graph; +} + +function graphBucketForFile(relativePath: string): keyof TeamPackageGraph | null { + if (relativePath.endsWith("/AGENTS.md") || relativePath === "AGENTS.md") return "agents"; + if (relativePath.endsWith("/PROJECT.md") || relativePath === "PROJECT.md") return "projects"; + if (relativePath.endsWith("/TASK.md") || relativePath === "TASK.md") return "tasks"; + if (relativePath.endsWith("/SKILL.md") || relativePath === "SKILL.md") return "skills"; + return null; +} + +function validateLocalReferences( + teamDir: string, + teamFrontmatter: Record, + graph: TeamPackageGraph, + agentSlugs: string[], + projectSlugs: string[], + errors: string[], +) { + const manager = asString(teamFrontmatter.manager); + const rootAgentSlugs: string[] = []; + + if (!manager) { + errors.push(`${TEAM_ENTRYPOINT} frontmatter must include manager.`); + } else { + const managerPath = resolveTeamReference(teamDir, TEAM_ENTRYPOINT, manager, errors); + const managerAgent = managerPath ? graph.agents.find((agent) => agent.relativePath === managerPath) : null; + if (!managerAgent) { + errors.push(`${TEAM_ENTRYPOINT} manager must resolve to an AGENTS.md file inside the team package: ${manager}.`); + } else { + rootAgentSlugs.push(readSlug(managerAgent, "agent", errors)); + } + } + + for (const include of readIncludeEntries(teamFrontmatter)) { + if (isExternalRef(include)) continue; + const resolved = resolveTeamReference(teamDir, TEAM_ENTRYPOINT, include, errors); + if (resolved && !existsSync(path.join(teamDir, resolved))) { + errors.push(`${TEAM_ENTRYPOINT} include does not exist: ${include}.`); + } + } + + for (const agent of graph.agents) { + const reportsTo = asString(agent.frontmatter.reportsTo); + if (reportsTo && reportsTo !== "null" && !agentSlugs.includes(reportsTo)) { + errors.push(`${agent.relativePath} reportsTo references unknown agent slug "${reportsTo}".`); + } + } + + for (const project of graph.projects) { + const owner = asString(project.frontmatter.owner) ?? asString(project.frontmatter.leadAgent); + if (owner && !agentSlugs.includes(owner)) { + errors.push(`${project.relativePath} owner references unknown agent slug "${owner}".`); + } + } + + for (const task of graph.tasks) { + const assignee = asString(task.frontmatter.assignee); + if (assignee && !agentSlugs.includes(assignee)) { + errors.push(`${task.relativePath} assignee references unknown agent slug "${assignee}".`); + } + const project = asString(task.frontmatter.project); + if (project && !projectSlugs.includes(project)) { + errors.push(`${task.relativePath} project references unknown project slug "${project}".`); + } + } + + return Array.from(new Set(rootAgentSlugs.filter(Boolean))).sort(); +} + +function collectRequiredSkills( + teamDir: string, + teamFrontmatter: Record, + graph: TeamPackageGraph, + catalogSkills: CatalogSkillSummary[], + agentSlugs: string[], + localSkillSlugs: string[], + errors: string[], +) { + const requirements = new Map(); + + function upsert(requirement: CatalogTeamSkillRequirement) { + const key = requirementIdentity(requirement); + const existing = requirements.get(key); + if (!existing) { + requirements.set(key, requirement); + return; + } + existing.agentSlugs = Array.from(new Set([...existing.agentSlugs, ...requirement.agentSlugs])).sort(); + } + + for (const agent of graph.agents) { + const agentSlug = readSlug(agent, "agent", errors); + const skills = readStringArrayField(agent.frontmatter.skills, "skills", agent.relativePath, errors); + for (const skillRef of skills) { + upsert(resolveSkillRequirement(skillRef, [agentSlug], catalogSkills, localSkillSlugs, errors, agent.relativePath)); + } + } + + for (const declared of readRequiredSkillEntries(teamFrontmatter, errors)) { + upsert(resolveDeclaredSkillRequirement(teamDir, declared, catalogSkills, localSkillSlugs, agentSlugs, errors)); + } + + return Array.from(requirements.values()).sort((a, b) => `${a.type}:${a.ref}`.localeCompare(`${b.type}:${b.ref}`)); +} + +function requirementIdentity(requirement: CatalogTeamSkillRequirement) { + if (requirement.type === "catalog") return `catalog:${requirement.catalogSkillId ?? requirement.catalogSkillKey ?? requirement.ref}`; + if (requirement.type === "local") return `local:${requirement.localPath ?? requirement.ref}`; + return `${requirement.type}:${requirement.sourceLocator ?? requirement.ref}`; +} + +function resolveSkillRequirement( + ref: string, + agentSlugs: string[], + catalogSkills: CatalogSkillSummary[], + localSkillSlugs: string[], + errors: string[], + prefix: string, +): CatalogTeamSkillRequirement { + if (localSkillSlugs.includes(ref)) { + return { + type: "local", + ref, + agentSlugs: agentSlugs.sort(), + resolved: true, + localPath: `skills/${ref}/SKILL.md`, + }; + } + + const catalogSkill = resolveCatalogSkill(ref, catalogSkills); + if (catalogSkill) { + return { + type: "catalog", + ref, + agentSlugs: agentSlugs.sort(), + resolved: true, + catalogSkillId: catalogSkill.id, + catalogSkillKey: catalogSkill.key, + }; + } + + errors.push(`${prefix} skill reference "${ref}" does not resolve to a local team skill or @paperclipai/skills-catalog skill.`); + return { + type: "catalog", + ref, + agentSlugs: agentSlugs.sort(), + resolved: false, + }; +} + +function resolveDeclaredSkillRequirement( + teamDir: string, + declared: unknown, + catalogSkills: CatalogSkillSummary[], + localSkillSlugs: string[], + agentSlugs: string[], + errors: string[], +): CatalogTeamSkillRequirement { + if (typeof declared === "string") { + return resolveSkillRequirement(declared.trim(), [], catalogSkills, localSkillSlugs, errors, TEAM_ENTRYPOINT); + } + + if (!isPlainRecord(declared)) { + errors.push(`${TEAM_ENTRYPOINT} requiredSkills entries must be strings or objects.`); + return { type: "catalog", ref: "", agentSlugs: [], resolved: false }; + } + + const type = asString(declared.type) ?? asString(declared.sourceType) ?? "catalog"; + const ref = asString(declared.ref) + ?? asString(declared.catalogSkillId) + ?? asString(declared.key) + ?? asString(declared.slug) + ?? asString(declared.url) + ?? asString(declared.path) + ?? ""; + const requirementAgentSlugs = readStringArrayLoose(declared.agentSlugs).filter((slug) => agentSlugs.includes(slug)).sort(); + + if (!isSkillRequirementType(type)) { + errors.push(`${TEAM_ENTRYPOINT} requiredSkills type "${type}" is not supported.`); + return { type: "catalog", ref, agentSlugs: requirementAgentSlugs, resolved: false }; + } + + if (!ref) { + errors.push(`${TEAM_ENTRYPOINT} requiredSkills ${type} entry must include a ref, key, slug, url, or path.`); + return { type, ref, agentSlugs: requirementAgentSlugs, resolved: false }; + } + + if (type === "catalog") { + return resolveSkillRequirement(ref, requirementAgentSlugs, catalogSkills, localSkillSlugs, errors, TEAM_ENTRYPOINT); + } + + if (type === "local") { + const localPath = ref.endsWith("/SKILL.md") ? ref : `skills/${ref}/SKILL.md`; + const normalized = resolveTeamReference(teamDir, TEAM_ENTRYPOINT, localPath, errors); + const localSlug = path.posix.basename(path.posix.dirname(localPath)); + const resolved = Boolean(normalized && localSkillSlugs.includes(localSlug)); + if (!resolved) errors.push(`${TEAM_ENTRYPOINT} required local skill "${ref}" does not resolve to skills//SKILL.md.`); + return { + type: "local", + ref, + agentSlugs: requirementAgentSlugs, + resolved, + localPath, + }; + } + + return { + type, + ref, + agentSlugs: requirementAgentSlugs, + resolved: true, + sourceLocator: ref, + sourceRef: asString(declared.sourceRef) ?? asString(declared.commit) ?? undefined, + }; +} + +function readRequiredSkillEntries(frontmatter: Record, errors: string[]) { + const value = frontmatter.requiredSkills; + if (value === undefined) return []; + if (!Array.isArray(value)) { + errors.push(`${TEAM_ENTRYPOINT} frontmatter field requiredSkills must be an array.`); + return []; + } + return value; +} + +function collectEnvInputs(graph: TeamPackageGraph): CatalogTeamEnvInputSummary[] { + const out: CatalogTeamEnvInputSummary[] = []; + + for (const agent of graph.agents) { + const agentSlug = asString(agent.frontmatter.slug) ?? slugFromEntityPath(agent.relativePath); + out.push(...readEnvInputs(agent.frontmatter, agentSlug, null)); + } + + for (const project of graph.projects) { + const projectSlug = asString(project.frontmatter.slug) ?? slugFromEntityPath(project.relativePath); + out.push(...readEnvInputs(project.frontmatter, null, projectSlug)); + } + + const seen = new Set(); + return out.filter((input) => { + const key = `${input.agentSlug ?? ""}:${input.projectSlug ?? ""}:${input.key}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }).sort((a, b) => `${a.agentSlug ?? ""}:${a.projectSlug ?? ""}:${a.key}`.localeCompare(`${b.agentSlug ?? ""}:${b.projectSlug ?? ""}:${b.key}`)); +} + +function readEnvInputs( + frontmatter: Record, + agentSlug: string | null, + projectSlug: string | null, +): CatalogTeamEnvInputSummary[] { + const inputs = isPlainRecord(frontmatter.inputs) ? frontmatter.inputs : null; + const env = inputs && isPlainRecord(inputs.env) ? inputs.env : null; + if (!env) return []; + + return Object.entries(env).flatMap(([key, value]) => { + if (!isPlainRecord(value)) return []; + return [{ + key, + agentSlug, + projectSlug, + kind: value.kind === "plain" ? "plain" : "secret", + requirement: value.requirement === "required" ? "required" : "optional", + } satisfies CatalogTeamEnvInputSummary]; + }); +} + +function collectSourceRefs( + teamFrontmatter: Record, + requiredSkills: CatalogTeamSkillRequirement[], +): CatalogTeamSourceRef[] { + const refs: CatalogTeamSourceRef[] = []; + + for (const include of readIncludeEntries(teamFrontmatter)) { + if (isExternalRef(include)) { + refs.push({ type: "include", ref: include, pinned: isPinnedExternalRef(include) }); + } + } + + for (const skill of requiredSkills) { + if (skill.type === "catalog" || skill.type === "local") continue; + refs.push({ + type: skill.type, + ref: skill.sourceLocator ?? skill.ref, + pinned: isPinnedExternalRef(skill.sourceRef ?? skill.sourceLocator ?? skill.ref), + }); + } + + refs.sort((a, b) => `${a.type}:${a.ref}`.localeCompare(`${b.type}:${b.ref}`)); + return refs; +} + +function readIncludeEntries(frontmatter: Record) { + const includes = frontmatter.includes; + if (!Array.isArray(includes)) return []; + return includes.flatMap((entry) => { + if (typeof entry === "string") return [entry.trim()].filter(Boolean); + if (isPlainRecord(entry)) { + const pathValue = asString(entry.path); + return pathValue ? [pathValue] : []; + } + return []; + }); +} + +function resolveTeamReference(teamDir: string, fromPath: string, ref: string, errors: string[]) { + if (isExternalRef(ref)) return null; + const normalizedRef = ref.replace(/\\/g, "/"); + if (path.posix.isAbsolute(normalizedRef)) { + errors.push(`${fromPath} reference must be relative, not absolute: ${ref}.`); + return null; + } + + const absolute = path.resolve(teamDir, path.dirname(fromPath), normalizedRef); + const relative = toPosixPath(path.relative(teamDir, absolute)); + if (path.isAbsolute(relative) || relative.split("/").includes("..")) { + errors.push(`${fromPath} reference escapes the team package: ${ref}.`); + return null; + } + return relative; +} + +function readStringArrayField( + value: unknown, + field: string, + prefix: string, + errors: string[], +) { + const parsed = asStringArray(value); + if (!parsed) { + errors.push(`${prefix} frontmatter field ${field} must be an array of strings.`); + return []; + } + return parsed; +} + +function readStringArrayLoose(value: unknown) { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean) + : []; +} + +function collectSlugs(files: ParsedTeamFile[], label: string, errors: string[]) { + const slugs = files.map((file) => readSlug(file, label, errors)).filter(Boolean); + collectDuplicateValues(slugs, label, errors); + return slugs; +} + +function readSlug(file: ParsedTeamFile, label: string, errors: string[]) { + const slug = asString(file.frontmatter.slug) ?? slugFromEntityPath(file.relativePath); + validateSlug(`${label} slug`, slug, file.relativePath, errors); + return slug; +} + +function slugFromEntityPath(relativePath: string) { + return path.posix.basename(path.posix.dirname(relativePath)); +} + +function classifyCatalogFile(relativePath: string): CatalogTeamFileKind { + if (relativePath === TEAM_ENTRYPOINT) return "team"; + if (relativePath.endsWith("/AGENTS.md") || relativePath === "AGENTS.md") return "agent"; + if (relativePath.endsWith("/PROJECT.md") || relativePath === "PROJECT.md") return "project"; + if (relativePath.endsWith("/TASK.md") || relativePath === "TASK.md") return "task"; + if (relativePath.endsWith("/SKILL.md") || relativePath === "SKILL.md") return "skill"; + if (relativePath === ".paperclip.yaml") return "extension"; + if (relativePath === "README.md") return "readme"; + if (relativePath.startsWith("references/")) return "reference"; + if (relativePath.startsWith("scripts/")) return "script"; + if (relativePath.startsWith("assets/")) return "asset"; + if (relativePath.endsWith(".md") || relativePath.endsWith(".mdx")) return "markdown"; + return "other"; +} + +function deriveTrustLevel(files: CatalogTeamFile[], sourceRefs: CatalogTeamSourceRef[]): CatalogTeamTrustLevel { + if (sourceRefs.length > 0) return "external_sources"; + if (files.some((file) => file.kind === "script")) return "scripts_executables"; + if (files.some((file) => file.kind === "asset" || file.kind === "other" || file.kind === "extension")) return "assets"; + return "markdown_only"; +} + +function buildContentHash(files: CatalogTeamFile[]) { + const hashInput = files.map((file) => ({ + path: file.path, + sha256: file.sha256, + })); + return `sha256:${sha256(Buffer.from(JSON.stringify(hashInput)))}`; +} + +function collectUniquenessErrors(teams: CatalogTeam[], errors: string[]) { + collectDuplicateErrors(teams, "id", errors); + collectDuplicateErrors(teams, "key", errors); + collectDuplicateErrors(teams, "slug", errors); +} + +function collectCandidateUniquenessErrors(candidates: TeamCandidate[], errors: string[]) { + const projected = candidates.map((candidate) => ({ + id: `paperclipai:${candidate.kind}:${candidate.category}:${candidate.slug}`, + key: `paperclipai/${candidate.kind}/${candidate.category}/${candidate.slug}`, + slug: candidate.slug, + path: toPosixPath(path.join("catalog", candidate.kind, candidate.category, candidate.slug)), + })) as CatalogTeam[]; + collectUniquenessErrors(projected, errors); +} + +function collectDuplicateErrors(teams: CatalogTeam[], field: "id" | "key" | "slug", errors: string[]) { + const seen = new Map(); + for (const team of teams) { + const value = team[field]; + const first = seen.get(value); + if (first) { + errors.push(`Duplicate catalog ${field} "${value}" in ${first} and ${team.path}.`); + continue; + } + seen.set(value, team.path); + } +} + +function collectDuplicateValues(values: string[], label: string, errors: string[]) { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + errors.push(`Duplicate ${label} "${value}" in team package.`); + } + seen.add(value); + } +} + +function resolveCatalogSkill(ref: string, catalogSkills: CatalogSkillSummary[]) { + const exact = catalogSkills.find((skill) => skill.id === ref || skill.key === ref); + if (exact) return exact; + const slugMatches = catalogSkills.filter((skill) => skill.slug === ref); + return slugMatches.length === 1 ? slugMatches[0]! : null; +} + +function isSkillRequirementType(value: string): value is CatalogTeamSkillRequirementType { + return value === "catalog" + || value === "local" + || value === "skills_sh" + || value === "github" + || value === "url" + || value === "local_path" + || value === "agent_package"; +} + +function validateSlug(label: string, value: string, prefix: string, errors: string[]) { + if (!SLUG_PATTERN.test(value)) { + errors.push(`${prefix} has invalid ${label} "${value}"; use lowercase URL slugs.`); + } +} + +async function sortedDirEntries(dir: string) { + return (await fs.readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name)); +} + +function sameManifestExceptGeneratedAt(a: CatalogManifest, b: CatalogManifest) { + return JSON.stringify({ ...a, generatedAt: "" }) === JSON.stringify({ ...b, generatedAt: "" }); +} + +function sha256(contents: Buffer) { + return createHash("sha256").update(contents).digest("hex"); +} + +function relativePackagePath(packageDir: string, absolutePath: string) { + return toPosixPath(path.relative(packageDir, absolutePath)); +} + +function toPosixPath(input: string) { + return input.split(path.sep).join("/"); +} + +function isPathInside(parent: string, child: string) { + const relativePath = path.relative(parent, child); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function isExternalRef(ref: string) { + return /^https?:\/\//.test(ref) || EXTERNAL_SOURCE_TYPES.has(ref.split(":")[0] ?? "") || LOCAL_PATH_SOURCE_TYPES.has(ref.split(":")[0] ?? ""); +} + +function isPinnedExternalRef(ref: string) { + return /[a-f0-9]{40}/i.test(ref) || /^sha256:[a-f0-9]{64}$/i.test(ref); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/teams-catalog/src/frontmatter.ts b/packages/teams-catalog/src/frontmatter.ts new file mode 100644 index 00000000000..a140f5c9765 --- /dev/null +++ b/packages/teams-catalog/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/teams-catalog/src/index.ts b/packages/teams-catalog/src/index.ts new file mode 100644 index 00000000000..a471f5f9585 --- /dev/null +++ b/packages/teams-catalog/src/index.ts @@ -0,0 +1,41 @@ +import catalogManifestJson from "../generated/catalog.json" with { type: "json" }; +import type { CatalogManifest, CatalogTeam } from "./types.js"; + +export type { + CatalogManifest, + CatalogTeam, + CatalogTeamCompatibility, + CatalogTeamEnvInputSummary, + CatalogTeamFile, + CatalogTeamFileKind, + CatalogTeamKind, + CatalogTeamSkillRequirement, + CatalogTeamSkillRequirementType, + CatalogTeamSourceRef, + CatalogTeamTrustLevel, + CatalogValidationResult, +} from "./types.js"; + +export const catalogManifest = catalogManifestJson as CatalogManifest; + +export const catalogTeams: CatalogTeam[] = catalogManifest.teams; + +const teamsById = new Map(catalogTeams.map((team) => [team.id, team])); +const teamsByKey = new Map(catalogTeams.map((team) => [team.key, team])); + +export function getCatalogTeam(id: string): CatalogTeam | null { + return teamsById.get(id) ?? null; +} + +export function resolveCatalogTeamRef(ref: string): CatalogTeam | null { + const normalized = ref.trim(); + if (normalized.length === 0) return null; + + const exactMatch = teamsById.get(normalized) ?? teamsByKey.get(normalized); + if (exactMatch) return exactMatch; + + const slugMatches = catalogTeams.filter((team) => team.slug === normalized); + if (slugMatches.length === 1) return slugMatches[0]!; + + return null; +} diff --git a/packages/teams-catalog/src/shipped-catalog.test.ts b/packages/teams-catalog/src/shipped-catalog.test.ts new file mode 100644 index 00000000000..354312505b3 --- /dev/null +++ b/packages/teams-catalog/src/shipped-catalog.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { catalogManifest, catalogTeams, resolveCatalogTeamRef } from "./index.js"; +import type { CatalogTeam } from "./types.js"; + +const EXPECTED_BUNDLED_KEYS = [ + "paperclipai/bundled/company-defaults/core-exec-team", +]; + +const EXPECTED_OPTIONAL_KEYS = [ + "paperclipai/optional/content/content-machine", +]; + +describe("shipped teams catalog", () => { + it("ships the expected bundled and optional team fixtures", () => { + const bundledKeys = catalogTeams + .filter((team) => team.kind === "bundled") + .map((team) => team.key) + .sort(); + const optionalKeys = catalogTeams + .filter((team) => team.kind === "optional") + .map((team) => team.key) + .sort(); + + expect(bundledKeys).toEqual(EXPECTED_BUNDLED_KEYS); + expect(optionalKeys).toEqual(EXPECTED_OPTIONAL_KEYS); + }); + + it("keeps every shipped team free of executable scripts and external sources in Phase B", () => { + const risky = catalogTeams.filter( + (team) => team.trustLevel === "scripts_executables" || team.trustLevel === "external_sources", + ); + expect(risky, formatViolations("script-bearing or external-source teams require later security review", risky)).toEqual([]); + }); + + it("populates browse/search-relevant fields for every shipped team", () => { + const issues: string[] = []; + for (const team of catalogTeams) { + if (team.compatibility !== "compatible") { + issues.push(`${team.key} compatibility=${team.compatibility}`); + } + if (!team.description || team.description.length < 40) { + issues.push(`${team.key} description must be at least 40 characters for catalog browse/search`); + } + if (team.recommendedForCompanyTypes.length === 0) { + issues.push(`${team.key} must list recommendedForCompanyTypes`); + } + if (team.tags.length === 0) { + issues.push(`${team.key} must list tags`); + } + if (team.rootAgentSlugs.length === 0) { + issues.push(`${team.key} must list a root agent slug`); + } + } + expect(issues).toEqual([]); + }); + + it("uses canonical paperclipai keys derived from kind/category/slug", () => { + const violations: string[] = []; + for (const team of catalogTeams) { + const expectedKey = `paperclipai/${team.kind}/${team.category}/${team.slug}`; + const expectedId = `paperclipai:${team.kind}:${team.category}:${team.slug}`; + if (team.key !== expectedKey) violations.push(`${team.key} should be ${expectedKey}`); + if (team.id !== expectedId) violations.push(`${team.id} should be ${expectedId}`); + } + expect(violations).toEqual([]); + }); + + it("exposes a stable manifest header for downstream consumers", () => { + expect(catalogManifest.schemaVersion).toBe(1); + expect(catalogManifest.packageName).toBe("@paperclipai/teams-catalog"); + expect(catalogTeams.length).toBe(EXPECTED_BUNDLED_KEYS.length + EXPECTED_OPTIONAL_KEYS.length); + }); + + it("resolves shipped teams by id, key, and unique slug", () => { + const sample = catalogTeams.find((team) => team.key === "paperclipai/bundled/company-defaults/core-exec-team"); + expect(sample, "expected core-exec-team to ship in the bundled catalog").toBeDefined(); + if (!sample) return; + + expect(resolveCatalogTeamRef(sample.id)).toMatchObject({ key: sample.key }); + expect(resolveCatalogTeamRef(sample.key)).toMatchObject({ key: sample.key }); + expect(resolveCatalogTeamRef(sample.slug)).toMatchObject({ key: sample.key }); + }); +}); + +function formatViolations(label: string, teams: CatalogTeam[]) { + if (teams.length === 0) return label; + const detail = teams.map((team) => `${team.key} (${team.trustLevel})`).join(", "); + return `${label}: ${detail}`; +} diff --git a/packages/teams-catalog/src/types.ts b/packages/teams-catalog/src/types.ts new file mode 100644 index 00000000000..b104efd5d24 --- /dev/null +++ b/packages/teams-catalog/src/types.ts @@ -0,0 +1,114 @@ +export type CatalogTeamKind = "bundled" | "optional"; + +export type CatalogTeamTrustLevel = + | "markdown_only" + | "assets" + | "scripts_executables" + | "external_sources"; + +export type CatalogTeamCompatibility = "compatible" | "unknown" | "invalid"; + +export type CatalogTeamFileKind = + | "team" + | "agent" + | "project" + | "task" + | "skill" + | "extension" + | "readme" + | "reference" + | "script" + | "asset" + | "markdown" + | "other"; + +export type CatalogTeamSkillRequirementType = + | "catalog" + | "local" + | "skills_sh" + | "github" + | "url" + | "local_path" + | "agent_package"; + +export interface CatalogTeamSkillRequirement { + type: CatalogTeamSkillRequirementType; + ref: string; + agentSlugs: string[]; + resolved: boolean; + catalogSkillId?: string; + catalogSkillKey?: string; + localPath?: string; + sourceLocator?: string; + sourceRef?: string; +} + +export interface CatalogTeamEnvInputSummary { + key: string; + agentSlug: string | null; + projectSlug: string | null; + kind: "secret" | "plain"; + requirement: "required" | "optional"; +} + +export interface CatalogTeamSourceRef { + type: Exclude | "include"; + ref: string; + pinned: boolean; +} + +export interface CatalogTeamFile { + path: string; + kind: CatalogTeamFileKind; + sizeBytes: number; + sha256: string; +} + +export interface CatalogTeam { + id: string; + key: string; + kind: CatalogTeamKind; + category: string; + slug: string; + name: string; + description: string; + path: string; + entrypoint: "TEAM.md"; + schema: "agentcompanies/v1"; + defaultInstall: boolean; + recommendedForCompanyTypes: string[]; + tags: string[]; + counts: { + agents: number; + projects: number; + tasks: number; + routines: number; + localSkills: number; + catalogSkills: number; + externalSkillSources: number; + }; + rootAgentSlugs: string[]; + agentSlugs: string[]; + projectSlugs: string[]; + requiredSkills: CatalogTeamSkillRequirement[]; + envInputs: CatalogTeamEnvInputSummary[]; + sourceRefs: CatalogTeamSourceRef[]; + files: CatalogTeamFile[]; + trustLevel: CatalogTeamTrustLevel; + compatibility: CatalogTeamCompatibility; + contentHash: string; +} + +export interface CatalogManifest { + schemaVersion: 1; + packageName: "@paperclipai/teams-catalog"; + packageVersion: string; + generatedAt: string; + teams: CatalogTeam[]; +} + +export interface CatalogValidationResult { + valid: boolean; + errors: string[]; + manifest: CatalogManifest; +} diff --git a/packages/teams-catalog/tsconfig.json b/packages/teams-catalog/tsconfig.json new file mode 100644 index 00000000000..7f356d83911 --- /dev/null +++ b/packages/teams-catalog/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "." + }, + "include": ["generated/**/*.json", "scripts/**/*.ts", "src/**/*.ts"] +} diff --git a/packages/teams-catalog/vitest.config.ts b/packages/teams-catalog/vitest.config.ts new file mode 100644 index 00000000000..c1433e6ef38 --- /dev/null +++ b/packages/teams-catalog/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["src/**/*.test.ts"], + }, +});