Add teams catalog API and CLI

This commit is contained in:
Dotta committed 2026-06-05 03:00:03 +00:00
1 parent 967466bb9d
commit 2b5feda461
22 files changed
+2778 -1

No files matched your search

+276
View File
@@ -0,0 +1,276 @@
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { registerTeamCommands } from "../commands/client/teams.js";
const ORIGINAL_ENV = { ...process.env };
function makeProgram(): Command {
const program = new Command();
program.exitOverride();
program.configureOutput({
writeOut: () => undefined,
writeErr: () => undefined,
});
registerTeamCommands(program);
return program;
}
async function runCommand(args: string[]): Promise<void> {
await makeProgram().parseAsync(args, { from: "user" });
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function catalogTeam(overrides: Record<string, unknown> = {}) {
return {
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",
description: "A software development team with CTO, coder, and QA roles.",
path: "catalog/bundled/software-development/product-engineering",
entrypoint: "TEAM.md",
schema: "agentcompanies/v1",
defaultInstall: true,
recommendedForCompanyTypes: ["software"],
tags: ["engineering"],
counts: { agents: 3, projects: 1, tasks: 1, routines: 0, localSkills: 0, catalogSkills: 1, externalSkillSources: 0 },
rootAgentSlugs: ["cto"],
agentSlugs: ["cto", "senior-coder", "qa"],
projectSlugs: ["product-engineering"],
requiredSkills: [],
envInputs: [],
sourceRefs: [],
files: [{ path: "TEAM.md", kind: "team", sizeBytes: 128, sha256: "sha256:team" }],
trustLevel: "markdown_only",
compatibility: "compatible",
contentHash: "sha256:catalog-team",
...overrides,
};
}
describe("teams CLI commands", () => {
let fetchMock: ReturnType<typeof vi.fn>;
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
process.env = { ...ORIGINAL_ENV };
delete process.env.PAPERCLIP_API_URL;
delete process.env.PAPERCLIP_API_KEY;
delete process.env.PAPERCLIP_COMPANY_ID;
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
vi.spyOn(process.stdout, "write").mockImplementation(() => true);
});
afterEach(() => {
process.env = { ...ORIGINAL_ENV };
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it("browses catalog teams with filters in table output", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse([catalogTeam()]));
await runCommand([
"teams",
"browse",
"--kind",
"bundled",
"--category",
"software-development",
"--query",
"engineering",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/teams/catalog?kind=bundled&category=software-development&q=engineering",
expect.objectContaining({ method: "GET" }),
);
const rendered = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
expect(rendered).toContain("id");
expect(rendered).toContain("paperclipai:bundled:software-development:product-engineering");
});
it("searches catalog teams as JSON", async () => {
const rows = [catalogTeam()];
fetchMock.mockResolvedValueOnce(jsonResponse(rows));
await runCommand([
"teams",
"search",
"engineering",
"--kind",
"bundled",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/teams/catalog?kind=bundled&q=engineering",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(rows);
});
it("inspects catalog team detail by query ref so keys with slashes work", async () => {
const detail = catalogTeam();
fetchMock.mockResolvedValueOnce(jsonResponse(detail));
await runCommand([
"teams",
"inspect",
"paperclipai/bundled/software-development/product-engineering",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/teams/catalog/ref?ref=paperclipai%2Fbundled%2Fsoftware-development%2Fproduct-engineering",
expect.objectContaining({ method: "GET" }),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(detail);
});
it("previews catalog team installs with trust policy flags", async () => {
const result = {
team: catalogTeam(),
portabilityPreview: {
plan: { companyAction: "none", agentPlans: [], projectPlans: [], issuePlans: [] },
warnings: [],
errors: [],
},
skillPreparations: [],
warnings: [],
errors: [],
};
fetchMock.mockResolvedValueOnce(jsonResponse(result));
await runCommand([
"teams",
"preview",
"product-engineering",
"--target-manager-slug",
"engineering-lead",
"--allow-external-sources",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/preview?ref=product-engineering",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
targetManagerSlug: "engineering-lead",
sourcePolicy: { allowExternalSources: true },
}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
});
it("installs catalog teams and passes selection options", async () => {
const result = {
team: catalogTeam(),
portabilityImport: {
company: { id: "company-1", name: "Paperclip", action: "unchanged" },
agents: [],
projects: [],
envInputs: [],
warnings: [],
},
skillPreparations: [],
warnings: [],
};
fetchMock.mockResolvedValueOnce(jsonResponse(result, 201));
await runCommand([
"teams",
"install",
"product-engineering",
"--agent",
"cto",
"--selected-file",
"agents/cto/AGENTS.md",
"--collision-strategy",
"skip",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
"--json",
]);
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=product-engineering",
expect.objectContaining({
method: "POST",
body: JSON.stringify({
agents: ["cto"],
collisionStrategy: "skip",
selectedFiles: ["agents/cto/AGENTS.md"],
}),
}),
);
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
});
it("surfaces server blocks for unsafe local-path catalog team sources", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({
error: 'Local path skill source "../unsafe" is development-only and is not allowed for catalog team install.',
}, 422));
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
throw new Error(`exit:${code ?? 0}`);
}) as typeof process.exit);
await expect(runCommand([
"teams",
"install",
"unsafe-local-team",
"--company-id",
"company-1",
"--api-base",
"http://paperclip.test",
"--api-key",
"token",
])).rejects.toThrow("exit:1");
expect(fetchMock).toHaveBeenCalledWith(
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=unsafe-local-team",
expect.objectContaining({
method: "POST",
body: JSON.stringify({}),
}),
);
expect(String(errorSpy.mock.calls[0]?.[0])).toContain("API error 422");
expect(String(errorSpy.mock.calls[0]?.[0])).toContain("development-only");
});
});
+394
View File
@@ -0,0 +1,394 @@
import { Command } from "commander";
import type {
CatalogTeam,
CatalogTeamImportPreviewResult,
CatalogTeamInstallResult,
CatalogTeamInstallOptions,
CatalogTeamImportOptions,
CatalogTeamSourcePolicy,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
type ResolvedClientContext,
} from "./common.js";
interface TeamBrowseOptions extends BaseClientOptions {
kind?: string;
category?: string;
query?: string;
}
interface TeamPreviewOptions extends BaseClientOptions {
companyId?: string;
targetManagerAgentId?: string;
targetManagerSlug?: string;
agent?: string[];
collisionStrategy?: "rename" | "skip" | "replace";
nameOverride?: string[];
selectedFile?: string[];
allowExternalSources?: boolean;
allowUnpinnedOptionalSources?: boolean;
allowLocalPathSources?: boolean;
}
interface TeamInstallOptions extends TeamPreviewOptions {}
export function registerTeamCommands(program: Command): void {
const teams = program.command("teams").description("App-shipped team catalog operations");
addCommonClientOptions(
teams
.command("browse")
.description("Browse app-shipped catalog teams without installing them")
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
.option("--category <slug>", "Catalog category filter")
.option("--query <text>", "Search catalog text")
.action(async (opts: TeamBrowseOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = await listCatalogTeams(ctx, opts);
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
printCatalogTeamRows(rows);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
teams
.command("search")
.description("Search app-shipped catalog teams without installing them")
.argument("<query>", "Search text")
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
.option("--category <slug>", "Catalog category filter")
.action(async (query: string, opts: TeamBrowseOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = await listCatalogTeams(ctx, { ...opts, query });
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
printCatalogTeamRows(rows);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
teams
.command("inspect")
.description("Inspect an app-shipped catalog team before installing it")
.argument("<catalogRef>", "Catalog team ID, key, or unique slug")
.option("--file <path>", "Print a specific catalog team file instead of the manifest detail")
.action(async (catalogRef: string, opts: BaseClientOptions & { file?: string }) => {
try {
const ctx = resolveCommandContext(opts);
if (opts.file?.trim()) {
const file = await getCatalogTeamFile(ctx, catalogRef, opts.file);
if (ctx.json) {
printOutput(file, { json: true });
return;
}
process.stdout.write(file?.content ?? "");
if (file?.content && !file.content.endsWith("\n")) {
process.stdout.write("\n");
}
return;
}
const detail = await getCatalogTeam(ctx, catalogRef);
if (ctx.json) {
printOutput(detail, { json: true });
return;
}
printCatalogTeamDetail(detail);
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
teams
.command("preview")
.description("Preview importing a catalog team into a company")
.argument("<catalogRef>", "Catalog team ID, key, or unique slug")
.option("--target-manager-agent-id <id>", "Existing agent ID that catalog root agents should report to")
.option("--target-manager-slug <slug>", "Portable manager slug that catalog root agents should report to")
.option("--agent <slug>", "Only preview selected agent slug; may be repeated", collectOptionValue, [] as string[])
.option("--collision-strategy <strategy>", "Import collision strategy (rename, skip, replace)")
.option("--name-override <slug=name>", "Override an imported entity name; may be repeated", collectOptionValue, [] as string[])
.option("--selected-file <path>", "Restrict import preview to selected portable file; may be repeated", collectOptionValue, [] as string[])
.option("--allow-external-sources", "Allow GitHub, URL, or skills.sh skill sources declared by the catalog team", false)
.option("--allow-unpinned-optional-sources", "Allow optional-team external skill sources that are not pinned to a commit", false)
.option("--allow-local-path-sources", "Development only: allow local-path skill sources declared by the catalog team", false)
.action(async (catalogRef: string, opts: TeamPreviewOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.post<CatalogTeamImportPreviewResult>(
catalogTeamCompanyPath(ctx.companyId, catalogRef, "preview"),
buildTeamOptions(opts),
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
printCatalogTeamPreview(result);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
teams
.command("install")
.description("Install a catalog team into a company")
.argument("<catalogRef>", "Catalog team ID, key, or unique slug")
.option("--target-manager-agent-id <id>", "Existing agent ID that catalog root agents should report to")
.option("--target-manager-slug <slug>", "Portable manager slug that catalog root agents should report to")
.option("--agent <slug>", "Only install selected agent slug; may be repeated", collectOptionValue, [] as string[])
.option("--collision-strategy <strategy>", "Import collision strategy (rename, skip, replace)")
.option("--name-override <slug=name>", "Override an imported entity name; may be repeated", collectOptionValue, [] as string[])
.option("--selected-file <path>", "Restrict install to selected portable file; may be repeated", collectOptionValue, [] as string[])
.option("--allow-external-sources", "Allow GitHub, URL, or skills.sh skill sources declared by the catalog team", false)
.option("--allow-unpinned-optional-sources", "Allow optional-team external skill sources that are not pinned to a commit", false)
.option("--allow-local-path-sources", "Development only: allow local-path skill sources declared by the catalog team", false)
.action(async (catalogRef: string, opts: TeamInstallOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const result = await ctx.api.post<CatalogTeamInstallResult>(
catalogTeamCompanyPath(ctx.companyId, catalogRef, "install"),
buildTeamInstallOptions(opts),
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
printCatalogTeamInstall(result);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
}
async function listCatalogTeams(
ctx: ResolvedClientContext,
opts: TeamBrowseOptions,
): Promise<CatalogTeam[]> {
const params = new URLSearchParams();
appendQueryParam(params, "kind", opts.kind);
appendQueryParam(params, "category", opts.category);
appendQueryParam(params, "q", opts.query);
const query = params.toString();
return (await ctx.api.get<CatalogTeam[]>(`/api/teams/catalog${query ? `?${query}` : ""}`)) ?? [];
}
async function getCatalogTeam(ctx: ResolvedClientContext, catalogRef: string): Promise<CatalogTeam> {
const ref = catalogRef.trim();
if (!ref) {
throw new Error("Catalog team reference is required.");
}
const detail = await ctx.api.get<CatalogTeam>(`/api/teams/catalog/ref?ref=${encodeURIComponent(ref)}`);
if (!detail) {
throw new Error(`Catalog team not found: ${catalogRef}`);
}
return detail;
}
async function getCatalogTeamFile(
ctx: ResolvedClientContext,
catalogRef: string,
filePath: string,
): Promise<{ content: string } | null> {
const ref = catalogRef.trim();
const path = filePath.trim();
if (!ref) throw new Error("Catalog team reference is required.");
if (!path) throw new Error("Catalog team file path is required.");
const params = new URLSearchParams({ ref, path });
return ctx.api.get(`/api/teams/catalog/ref/files?${params.toString()}`);
}
function catalogTeamCompanyPath(companyId: string | undefined, catalogRef: string, action: "preview" | "install") {
if (!companyId) throw new Error("Company ID is required.");
const params = new URLSearchParams({ ref: catalogRef.trim() });
return `/api/companies/${companyId}/teams/catalog/ref/${action}?${params.toString()}`;
}
function buildTeamOptions(opts: TeamPreviewOptions): CatalogTeamImportOptions {
return removeUndefined({
targetManagerAgentId: emptyStringToUndefined(opts.targetManagerAgentId),
targetManagerSlug: emptyStringToUndefined(opts.targetManagerSlug),
agents: opts.agent && opts.agent.length > 0 ? opts.agent : undefined,
collisionStrategy: opts.collisionStrategy,
nameOverrides: parseNameOverrides(opts.nameOverride),
selectedFiles: opts.selectedFile && opts.selectedFile.length > 0 ? opts.selectedFile : undefined,
sourcePolicy: buildSourcePolicy(opts),
});
}
function buildTeamInstallOptions(opts: TeamInstallOptions): CatalogTeamInstallOptions {
return buildTeamOptions(opts);
}
function buildSourcePolicy(opts: TeamPreviewOptions): CatalogTeamSourcePolicy | undefined {
const sourcePolicy = removeUndefined({
allowExternalSources: opts.allowExternalSources || undefined,
allowUnpinnedOptionalSources: opts.allowUnpinnedOptionalSources || undefined,
allowLocalPathSources: opts.allowLocalPathSources || undefined,
});
return Object.keys(sourcePolicy).length > 0 ? sourcePolicy : undefined;
}
function parseNameOverrides(values: string[] | undefined): Record<string, string> | undefined {
if (!values || values.length === 0) return undefined;
const result: Record<string, string> = {};
for (const raw of values) {
const separator = raw.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid --name-override "${raw}". Use slug=name.`);
}
const slug = raw.slice(0, separator).trim();
const name = raw.slice(separator + 1).trim();
if (!slug || !name) {
throw new Error(`Invalid --name-override "${raw}". Use slug=name.`);
}
result[slug] = name;
}
return result;
}
function removeUndefined<T extends Record<string, unknown>>(input: T): T {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined)) as T;
}
function emptyStringToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed || undefined;
}
function collectOptionValue(value: string, previous: string[]): string[] {
return [...previous, value];
}
function appendQueryParam(params: URLSearchParams, key: string, value: string | undefined): void {
const trimmed = value?.trim();
if (trimmed) {
params.set(key, trimmed);
}
}
function printCatalogTeamRows(rows: CatalogTeam[]): void {
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
printTable(rows.map((row) => ({
id: row.id,
key: row.key,
kind: row.kind,
category: row.category,
slug: row.slug,
name: row.name,
trust: row.trustLevel,
agents: row.counts.agents,
projects: row.counts.projects,
})));
}
function printCatalogTeamDetail(team: CatalogTeam): void {
console.log(
formatInlineRecord({
id: team.id,
key: team.key,
kind: team.kind,
category: team.category,
slug: team.slug,
name: team.name,
trust: team.trustLevel,
compatibility: team.compatibility,
contentHash: team.contentHash,
}),
);
console.log(`description=${team.description || "-"}`);
console.log(`recommendedForCompanyTypes=${team.recommendedForCompanyTypes.join(",") || "-"}`);
console.log(`tags=${team.tags.join(",") || "-"}`);
console.log(
`counts=agents:${team.counts.agents},projects:${team.counts.projects},tasks:${team.counts.tasks},skills:${team.counts.localSkills + team.counts.catalogSkills}`,
);
console.log("files:");
printTable(team.files.map((file) => ({
path: file.path,
kind: file.kind,
sizeBytes: file.sizeBytes,
sha256: file.sha256,
})));
}
function printCatalogTeamPreview(result: CatalogTeamImportPreviewResult | null): void {
if (!result) {
console.log("Catalog team preview returned no result.");
return;
}
const preview = result.portabilityPreview;
console.log(
`Catalog team preview: ${result.team.name} (${result.team.key}) agents=${preview.plan.agentPlans.length} projects=${preview.plan.projectPlans.length} issues=${preview.plan.issuePlans.length} warnings=${result.warnings.length} errors=${result.errors.length}`,
);
for (const warning of result.warnings) console.log(`warning=${warning}`);
for (const error of result.errors) console.log(`error=${error}`);
}
function printCatalogTeamInstall(result: CatalogTeamInstallResult | null): void {
if (!result) {
console.log("Catalog team install returned no result.");
return;
}
console.log(
`Catalog team installed: ${result.team.name} (${result.team.key}) agents=${result.portabilityImport.agents.length} projects=${result.portabilityImport.projects.length} warnings=${result.warnings.length}`,
);
for (const warning of result.warnings) console.log(`warning=${warning}`);
}
function printTable(rows: Array<Record<string, unknown>>): void {
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
const columns = Object.keys(rows[0] ?? {});
const widths = new Map(columns.map((column) => [column, column.length]));
for (const row of rows) {
for (const column of columns) {
widths.set(column, Math.max(widths.get(column) ?? 0, renderTableValue(row[column]).length));
}
}
console.log(columns.map((column) => column.padEnd(widths.get(column) ?? column.length)).join(" "));
console.log(columns.map((column) => "-".repeat(widths.get(column) ?? column.length)).join(" "));
for (const row of rows) {
console.log(
columns
.map((column) => renderTableValue(row[column]).padEnd(widths.get(column) ?? column.length))
.join(" "),
);
}
}
function renderTableValue(value: unknown): string {
if (value === null || value === undefined) return "-";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
+2
View File
@@ -23,6 +23,7 @@ import { registerFeedbackCommands } from "./commands/client/feedback.js";
import { registerSecretCommands } from "./commands/client/secrets.js";
import { registerCloudCommands } from "./commands/client/cloud.js";
import { registerSkillsCommands } from "./commands/client/skills.js";
import { registerTeamCommands } from "./commands/client/teams.js";
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
import { loadPaperclipEnvFile } from "./config/env.js";
import { initTelemetryFromConfigFile, flushTelemetry } from "./telemetry.js";
@@ -180,6 +181,7 @@ registerFeedbackCommands(program);
registerSecretCommands(program);
registerCloudCommands(program);
registerSkillsCommands(program);
registerTeamCommands(program);
registerWorktreeCommands(program);
registerEnvLabCommands(program);
registerPluginCommands(program);