[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:
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Command } from "commander";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CompanyPortabilityPreviewResult } from "@paperclipai/shared";
|
||||
import {
|
||||
buildCompanyDashboardUrl,
|
||||
@@ -8,10 +9,196 @@ import {
|
||||
buildSelectedFilesFromImportSelection,
|
||||
renderCompanyImportPreview,
|
||||
renderCompanyImportResult,
|
||||
registerCompanyCommands,
|
||||
resolveCompanyImportApplyConfirmationMode,
|
||||
resolveCompanyImportApiPath,
|
||||
} from "../commands/client/company.js";
|
||||
|
||||
const ORIGINAL_ENV = { ...process.env };
|
||||
const COMPANY_ID = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function makeProgram(): Command {
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
program.configureOutput({
|
||||
writeOut: () => undefined,
|
||||
writeErr: () => undefined,
|
||||
});
|
||||
registerCompanyCommands(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 company(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: COMPANY_ID,
|
||||
name: "Paperclip",
|
||||
description: null,
|
||||
status: "active",
|
||||
issuePrefix: "PAP",
|
||||
issueCounter: 1,
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
attachmentMaxBytes: 1073741824,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
feedbackDataSharingEnabled: false,
|
||||
feedbackDataSharingConsentAt: null,
|
||||
feedbackDataSharingConsentByUserId: null,
|
||||
feedbackDataSharingTermsVersion: null,
|
||||
brandColor: "#5c5fff",
|
||||
logoAssetId: null,
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
logoUrl: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("company 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);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...ORIGINAL_ENV };
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("gets the current company from an explicit company context without board-wide listing", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(company()));
|
||||
|
||||
await runCommand([
|
||||
"company",
|
||||
"current",
|
||||
"--company-id",
|
||||
COMPANY_ID,
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"agent-token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
`http://paperclip.test/api/companies/${COMPANY_ID}`,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ id: COMPANY_ID, name: "Paperclip" });
|
||||
});
|
||||
|
||||
it("gets the current company from agent authentication when no company context is set", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "agent-1", companyId: COMPANY_ID }))
|
||||
.mockResolvedValueOnce(jsonResponse(company()));
|
||||
|
||||
await runCommand([
|
||||
"company",
|
||||
"current",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"agent-token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://paperclip.test/api/agents/me",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
`http://paperclip.test/api/companies/${COMPANY_ID}`,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ id: COMPANY_ID, name: "Paperclip" });
|
||||
});
|
||||
|
||||
it("lists the scoped agent company when board-wide company listing is denied", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "Board access required" }, 403))
|
||||
.mockResolvedValueOnce(jsonResponse({ id: "agent-1", companyId: COMPANY_ID }))
|
||||
.mockResolvedValueOnce(jsonResponse(company()));
|
||||
|
||||
await runCommand([
|
||||
"company",
|
||||
"list",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"agent-token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://paperclip.test/api/companies",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/agents/me",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
`http://paperclip.test/api/companies/${COMPANY_ID}`,
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject([{ id: COMPANY_ID, name: "Paperclip" }]);
|
||||
});
|
||||
|
||||
it("explains that company creation requires board instance-admin authentication under agent auth", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "Board access required" }, 403));
|
||||
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
|
||||
throw new Error(`exit:${code ?? 0}`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
await expect(runCommand([
|
||||
"company",
|
||||
"create",
|
||||
"--payload-json",
|
||||
"{\"name\":\"Disposable\"}",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"agent-token",
|
||||
"--json",
|
||||
])).rejects.toThrow("exit:1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/companies",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
);
|
||||
const rendered = String(errorSpy.mock.calls[0]?.[0]);
|
||||
expect(rendered).toContain("Creating companies requires board/instance-admin authentication");
|
||||
expect(rendered).toContain("company list --json");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCompanyImportApiPath", () => {
|
||||
it("uses company-scoped preview route for existing-company dry runs", () => {
|
||||
expect(
|
||||
@@ -184,6 +371,8 @@ describe("renderCompanyImportPreview", () => {
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
reportsToExistingAgentId: null,
|
||||
reportsToExistingAgentSlug: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
@@ -401,6 +590,8 @@ describe("import selection catalog", () => {
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
reportsToExistingAgentId: null,
|
||||
reportsToExistingAgentSlug: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
@@ -558,6 +749,8 @@ describe("default adapter overrides", () => {
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
reportsToExistingAgentId: null,
|
||||
reportsToExistingAgentSlug: null,
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
@@ -575,6 +768,8 @@ describe("default adapter overrides", () => {
|
||||
icon: null,
|
||||
capabilities: null,
|
||||
reportsToSlug: null,
|
||||
reportsToExistingAgentId: null,
|
||||
reportsToExistingAgentSlug: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function installedCatalogTeam(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
catalogId: "paperclipai:bundled:software-development:product-engineering",
|
||||
catalogKey: "paperclipai/bundled/software-development/product-engineering",
|
||||
present: true,
|
||||
currentContentHash: "sha256:catalog-team",
|
||||
installedOriginHashes: ["sha256:catalog-team"],
|
||||
agentCount: 3,
|
||||
outOfDate: false,
|
||||
...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("lists catalog teams with installed status for a company", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([
|
||||
catalogTeam(),
|
||||
catalogTeam({
|
||||
id: "paperclipai:optional:content:content-machine",
|
||||
key: "paperclipai/optional/content/content-machine",
|
||||
kind: "optional",
|
||||
category: "content",
|
||||
slug: "content-machine",
|
||||
name: "Content Machine",
|
||||
contentHash: "sha256:content-current",
|
||||
}),
|
||||
]))
|
||||
.mockResolvedValueOnce(jsonResponse([
|
||||
installedCatalogTeam({
|
||||
currentContentHash: "sha256:catalog-team",
|
||||
installedOriginHashes: ["sha256:older"],
|
||||
outOfDate: true,
|
||||
}),
|
||||
]));
|
||||
|
||||
await runCommand([
|
||||
"teams",
|
||||
"list",
|
||||
"--kind",
|
||||
"bundled",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://paperclip.test/api/teams/catalog?kind=bundled",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/company-1/teams/catalog/installed",
|
||||
expect.objectContaining({ method: "GET" }),
|
||||
);
|
||||
const rendered = logSpy.mock.calls.map((call) => String(call[0])).join("\n");
|
||||
expect(rendered).toContain("installedStatus");
|
||||
expect(rendered).toContain("out_of_date");
|
||||
expect(rendered).toContain("not_installed");
|
||||
});
|
||||
|
||||
it("lists installed status as JSON including removed catalog teams", async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse([catalogTeam()]))
|
||||
.mockResolvedValueOnce(jsonResponse([
|
||||
installedCatalogTeam(),
|
||||
installedCatalogTeam({
|
||||
catalogId: "paperclipai:removed:team",
|
||||
catalogKey: "paperclipai/removed/team",
|
||||
present: false,
|
||||
currentContentHash: null,
|
||||
installedOriginHashes: ["sha256:removed"],
|
||||
agentCount: 2,
|
||||
}),
|
||||
]));
|
||||
|
||||
await runCommand([
|
||||
"teams",
|
||||
"list",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
const rows = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
|
||||
expect(rows).toMatchObject([
|
||||
{
|
||||
catalogId: "paperclipai:bundled:software-development:product-engineering",
|
||||
catalogKey: "paperclipai/bundled/software-development/product-engineering",
|
||||
installedStatus: "installed",
|
||||
installedAgentCount: 3,
|
||||
outOfDate: false,
|
||||
},
|
||||
{
|
||||
catalogId: "paperclipai:removed:team",
|
||||
catalogKey: "paperclipai/removed/team",
|
||||
installedStatus: "installed_missing",
|
||||
installedAgentCount: 2,
|
||||
present: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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",
|
||||
"--secret-value",
|
||||
"agent:cto:OPENAI_API_KEY=sk-test",
|
||||
"--adapter-override",
|
||||
"cto=opencode_local",
|
||||
"--company-id",
|
||||
"company/1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/companies/company%2F1/teams/catalog/ref/install?ref=product-engineering",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
agents: ["cto"],
|
||||
collisionStrategy: "skip",
|
||||
selectedFiles: ["agents/cto/AGENTS.md"],
|
||||
adapterOverrides: { cto: { adapterType: "opencode_local" } },
|
||||
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-test" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual(result);
|
||||
});
|
||||
|
||||
it("requests board approval when agent-run install lacks create-agent permission", async () => {
|
||||
const approval = {
|
||||
id: "approval-1",
|
||||
companyId: "company-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "Missing permission: can create agents" }, 403))
|
||||
.mockResolvedValueOnce(jsonResponse(approval, 201));
|
||||
|
||||
await runCommand([
|
||||
"teams",
|
||||
"install",
|
||||
"product-engineering",
|
||||
"--target-manager-agent-id",
|
||||
"manager-1",
|
||||
"--collision-strategy",
|
||||
"rename",
|
||||
"--secret-value",
|
||||
"agent:cto:OPENAI_API_KEY=sk-live-secret",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--request-approval-on-forbidden",
|
||||
"--approval-issue-id",
|
||||
"11111111-1111-4111-8111-111111111111",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=product-engineering",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
targetManagerAgentId: "manager-1",
|
||||
collisionStrategy: "rename",
|
||||
secretValues: { "agent:cto:OPENAI_API_KEY": "sk-live-secret" },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/company-1/approvals",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(String),
|
||||
}),
|
||||
);
|
||||
const approvalPayload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body));
|
||||
expect(approvalPayload).toMatchObject({
|
||||
type: "request_board_approval",
|
||||
issueIds: ["11111111-1111-4111-8111-111111111111"],
|
||||
payload: {
|
||||
title: "Approve catalog team install: product-engineering",
|
||||
installAttempt: {
|
||||
companyId: "company-1",
|
||||
catalogRef: "product-engineering",
|
||||
deniedReason: "Missing permission: can create agents",
|
||||
options: {
|
||||
targetManagerAgentId: "manager-1",
|
||||
collisionStrategy: "rename",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const rendered = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
|
||||
expect(rendered).toMatchObject({
|
||||
status: "approval_requested",
|
||||
approval,
|
||||
installAttempt: {
|
||||
companyId: "company-1",
|
||||
catalogRef: "product-engineering",
|
||||
deniedReason: "Missing permission: can create agents",
|
||||
options: {
|
||||
targetManagerAgentId: "manager-1",
|
||||
collisionStrategy: "rename",
|
||||
secretValues: { "agent:cto:OPENAI_API_KEY": "[redacted]" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(approvalPayload)).not.toContain("sk-live-secret");
|
||||
expect(String(logSpy.mock.calls[0]?.[0])).not.toContain("sk-live-secret");
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("auto-requests board approval for forbidden installs inside a Paperclip task run", async () => {
|
||||
process.env.PAPERCLIP_TASK_ID = "11111111-1111-4111-8111-111111111111";
|
||||
const approval = {
|
||||
id: "approval-2",
|
||||
companyId: "company-1",
|
||||
type: "request_board_approval",
|
||||
requestedByAgentId: "agent-1",
|
||||
requestedByUserId: null,
|
||||
status: "pending",
|
||||
payload: {},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
createdAt: "2026-06-04T00:00:00.000Z",
|
||||
updatedAt: "2026-06-04T00:00:00.000Z",
|
||||
};
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(jsonResponse({ error: "Missing permission: can create agents" }, 403))
|
||||
.mockResolvedValueOnce(jsonResponse(approval, 201));
|
||||
|
||||
await runCommand([
|
||||
"teams",
|
||||
"install",
|
||||
"product-engineering",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
"--json",
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"http://paperclip.test/api/companies/company-1/approvals",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.any(String),
|
||||
}),
|
||||
);
|
||||
const approvalPayload = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body));
|
||||
expect(approvalPayload).toMatchObject({
|
||||
type: "request_board_approval",
|
||||
issueIds: ["11111111-1111-4111-8111-111111111111"],
|
||||
payload: {
|
||||
installAttempt: {
|
||||
companyId: "company-1",
|
||||
catalogRef: "product-engineering",
|
||||
deniedReason: "Missing permission: can create agents",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
|
||||
status: "approval_requested",
|
||||
approval,
|
||||
});
|
||||
expect(errorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not request board approval for unrelated forbidden install errors", async () => {
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ error: "Agent key cannot access another company" }, 403));
|
||||
vi.spyOn(process, "exit").mockImplementation(((code?: string | number | null) => {
|
||||
throw new Error(`exit:${code ?? 0}`);
|
||||
}) as typeof process.exit);
|
||||
|
||||
await expect(runCommand([
|
||||
"teams",
|
||||
"install",
|
||||
"product-engineering",
|
||||
"--company-id",
|
||||
"company-1",
|
||||
"--request-approval-on-forbidden",
|
||||
"--api-base",
|
||||
"http://paperclip.test",
|
||||
"--api-key",
|
||||
"token",
|
||||
])).rejects.toThrow("exit:1");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://paperclip.test/api/companies/company-1/teams/catalog/ref/install?ref=product-engineering",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
);
|
||||
expect(String(errorSpy.mock.calls[0]?.[0])).toContain("Agent key cannot access another company");
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user