[codex] Add teams catalog extraction (#7550)
Fixes #7551 ## Thinking Path > - Paperclip is the control plane for AI-agent companies, and reusable company/team setup is part of making those companies faster to launch. > - The teams catalog work introduces app-shipped team templates that can be browsed, previewed, and installed into a company. > - Catalog installation crosses several contracts: bundled package contents, shared API types, server import/install behavior, CLI workflows, and the board UI. > - Agents also need a safe path through catalog installs: scoped company selection, explicit source policy, approval fallback for agent creation, and preserved catalog provenance. > - This pull request extracts the completed teams catalog branch into one reviewable PR on top of `public-gh/master`. > - The benefit is a reusable teams catalog foundation with server, CLI, package, docs, and hidden UI surfaces kept in sync. ## What Changed - Added the `@paperclipai/teams-catalog` package with bundled/optional team definitions, generated manifest, validators, catalog builder tests, and migration notes. - Added shared teams catalog types/validators plus server routes and services for listing, previewing, and installing catalog teams. - Integrated catalog install with company portability, skill/source policy checks, provenance metadata, origin hashes, target-manager reparenting, and installed/out-of-date detection. - Added CLI `teams` commands and agent-safe company selection behavior, including `company current` and approval fallback for forbidden agent-run installs. - Added hidden Team Catalog UI/API/query surfaces, Storybook fixtures, and targeted UI tests while keeping the UI route out of primary navigation. - Added docs for CLI/company/teams catalog behavior and removed generated screenshot artifacts from the PR diff. ## Verification - `pnpm exec vitest run cli/src/__tests__/company.test.ts cli/src/__tests__/teams.test.ts packages/teams-catalog/src/catalog-builder.test.ts packages/teams-catalog/src/shipped-catalog.test.ts server/src/__tests__/agent-permissions-service.test.ts server/src/__tests__/company-portability.test.ts server/src/__tests__/company-skills-service.test.ts server/src/__tests__/teams-catalog-routes.test.ts server/src/__tests__/teams-catalog-service.test.ts server/src/__tests__/teams-catalog-install-no-overrides.test.ts ui/src/lib/company-routes.test.ts ui/src/pages/TeamCard.test.tsx ui/src/pages/TeamCatalog.test.tsx ui/src/pages/useInstallTeamCatalogEntry.test.tsx` - `pnpm --filter @paperclipai/shared typecheck && pnpm --filter @paperclipai/teams-catalog typecheck && pnpm --filter paperclipai typecheck && pnpm --filter @paperclipai/server typecheck && pnpm --filter @paperclipai/ui typecheck` - Confirmed branch is rebased onto `public-gh/master` (`78dc3625a`) and `public-gh/master` is an ancestor of `HEAD`. - Confirmed PR diff excludes `pnpm-lock.yaml`, `.github/workflows/*`, generated screenshot images, and screenshot helper scripts. ## Risks - Medium review surface: this crosses package generation, shared contracts, server install behavior, CLI, docs, and hidden UI code. - Catalog install behavior creates agents/projects/tasks/skills and must keep company scoping, permissions, source policy, and provenance checks strict. - `pnpm-lock.yaml` is intentionally excluded per repo policy; CI/default-branch automation owns lockfile refresh. - The Team Catalog UI is included but hidden from primary navigation, so future enablement should re-check visual QA before exposure. > 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`. > > ROADMAP checked: this aligns with reusable companies/templates and plugin-adjacent onboarding work. This PR packages work already developed on the Paperclip task branch for review. ## Model Used - OpenAI Codex, GPT-5 series coding agent in this Paperclip session; exact runtime context window was not exposed. Used shell, git, `gh`, and local test/typecheck tooling. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots, or documented why screenshots are intentionally omitted - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<bundled|optional>/<category>/<slug>/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<string, string>;
|
||||
},
|
||||
) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<CatalogTeamKind>(["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<string, unknown>;
|
||||
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<BuildCatalogManifestResult> {
|
||||
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<BuildCatalogManifestResult> {
|
||||
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<BuildCatalogManifestResult> {
|
||||
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<CatalogManifest | null> {
|
||||
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<CatalogSkillSummary[]> {
|
||||
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/<bundled|optional>/<category>/<slug>/TEAM.md.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await visit(catalogDir);
|
||||
}
|
||||
|
||||
async function buildCatalogTeam(
|
||||
packageDir: string,
|
||||
candidate: TeamCandidate,
|
||||
catalogSkills: CatalogSkillSummary[],
|
||||
errors: string[],
|
||||
): Promise<CatalogTeam | null> {
|
||||
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<CatalogTeamFile[]> {
|
||||
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<TeamPackageGraph> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
graph: TeamPackageGraph,
|
||||
catalogSkills: CatalogSkillSummary[],
|
||||
agentSlugs: string[],
|
||||
localSkillSlugs: string[],
|
||||
errors: string[],
|
||||
) {
|
||||
const requirements = new Map<string, CatalogTeamSkillRequirement>();
|
||||
|
||||
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/<slug>/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<string, unknown>, 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<string>();
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>) {
|
||||
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<string, string>();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
export interface MarkdownDoc {
|
||||
frontmatter: Record<string, unknown>;
|
||||
body: string;
|
||||
hasFrontmatter: boolean;
|
||||
}
|
||||
|
||||
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)?\d*$/.test(trimmed)) return Number(trimmed);
|
||||
if (
|
||||
trimmed.startsWith("\"") ||
|
||||
trimmed.startsWith("[") ||
|
||||
trimmed.startsWith("{")
|
||||
) {
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { catalogManifest, catalogTeams, resolveCatalogTeamRef } from "./index.js";
|
||||
import { asBoolean, asString, parseFrontmatterMarkdown } from "./frontmatter.js";
|
||||
import type { CatalogTeam } from "./types.js";
|
||||
|
||||
const EXPECTED_BUNDLED_KEYS = [
|
||||
"paperclipai/bundled/company-defaults/core-exec-team",
|
||||
"paperclipai/bundled/product/product-design",
|
||||
"paperclipai/bundled/software-development/product-engineering",
|
||||
];
|
||||
|
||||
const EXPECTED_OPTIONAL_KEYS = [
|
||||
"paperclipai/optional/content/content-machine",
|
||||
];
|
||||
|
||||
const PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
it("declares a valid project for every shipped recurring task", () => {
|
||||
const issues: string[] = [];
|
||||
|
||||
for (const team of catalogTeams) {
|
||||
for (const file of team.files.filter((entry) => entry.kind === "task")) {
|
||||
const absolutePath = path.join(PACKAGE_DIR, team.path, file.path);
|
||||
const parsed = parseFrontmatterMarkdown(fs.readFileSync(absolutePath, "utf8"));
|
||||
if (!asBoolean(parsed.frontmatter.recurring)) continue;
|
||||
|
||||
const project = asString(parsed.frontmatter.project);
|
||||
if (!project) {
|
||||
issues.push(`${team.key}/${file.path} recurring task must declare a project`);
|
||||
continue;
|
||||
}
|
||||
if (!team.projectSlugs.includes(project)) {
|
||||
issues.push(`${team.key}/${file.path} project=${project} must match a team project`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(issues).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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<CatalogTeamSkillRequirementType, "catalog" | "local"> | "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;
|
||||
}
|
||||
Reference in New Issue
Block a user