[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:
Dotta
2026-06-05 07:55:49 -10:00
committed by GitHub
parent 3657854e5e
commit fff3832a01
80 changed files with 12449 additions and 470 deletions
@@ -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);
}
+154
View File
@@ -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;
}
+41
View File
@@ -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}`;
}
+114
View File
@@ -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;
}