[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
+196 -1
View File
@@ -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: {},
+581
View File
@@ -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");
});
});
+86 -2
View File
@@ -36,6 +36,10 @@ interface CompanyJsonOptions extends BaseClientOptions {
companyId?: string;
payloadJson?: string;
}
interface AgentMeResponse {
id: string;
companyId: string;
}
type CompanyDeleteSelectorMode = "auto" | "id" | "prefix";
type CompanyImportTargetMode = "new" | "existing";
type CompanyCollisionMode = "rename" | "skip" | "replace";
@@ -1095,7 +1099,7 @@ export function registerCompanyCommands(program: Command): void {
.action(async (opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
const rows = await listCompaniesForContext(ctx);
if (ctx.json) {
printOutput(rows, { json: true });
return;
@@ -1139,6 +1143,23 @@ export function registerCompanyCommands(program: Command): void {
}),
);
addCommonClientOptions(
company
.command("current")
.description("Get the current scoped company from --company-id, context, env, or agent authentication")
.action(async (opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
const companyId = await resolveCurrentCompanyId(ctx);
const row = await ctx.api.get<Company>(apiPath`/api/companies/${companyId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
addCommonClientOptions(
company
.command("stats")
@@ -1161,7 +1182,7 @@ export function registerCompanyCommands(program: Command): void {
.action(async (opts: CompanyJsonOptions) => {
try {
const ctx = resolveCommandContext(opts);
printOutput(await ctx.api.post("/api/companies", parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
printOutput(await createCompanyForContext(ctx, parseJson(opts.payloadJson ?? "{}")), { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
@@ -1673,6 +1694,69 @@ export function registerCompanyCommands(program: Command): void {
);
}
async function listCompaniesForContext(ctx: {
companyId?: string;
api: { get<T>(path: string): Promise<T | null> };
}): Promise<Company[]> {
try {
return (await ctx.api.get<Company[]>("/api/companies")) ?? [];
} catch (error) {
if (!isBoardAccessRequiredError(error)) {
throw error;
}
}
const companyId = await resolveCurrentCompanyId(ctx);
const scopedCompany = await ctx.api.get<Company>(apiPath`/api/companies/${companyId}`);
return scopedCompany ? [scopedCompany] : [];
}
async function createCompanyForContext(ctx: {
api: { post<T>(path: string, body?: unknown): Promise<T | null> };
}, payload: unknown): Promise<unknown> {
try {
return await ctx.api.post("/api/companies", payload);
} catch (error) {
if (isBoardAccessRequiredError(error) || isInstanceAdminRequiredError(error)) {
throw new Error(
"Creating companies requires board/instance-admin authentication. Agent API keys are scoped to one company; use `paperclipai company list --json` or `paperclipai company current --json` to select the scoped company, or rerun create with a board token/login.",
);
}
throw error;
}
}
async function resolveCurrentCompanyId(ctx: { companyId?: string; api: { get<T>(path: string): Promise<T | null> } }): Promise<string> {
const fromContext = ctx.companyId?.trim();
if (fromContext) return fromContext;
let agent: AgentMeResponse | null = null;
try {
agent = await ctx.api.get<AgentMeResponse>("/api/agents/me");
} catch (error) {
if (error instanceof ApiRequestError && (error.status === 401 || error.status === 403)) {
throw new Error(
"Current company is not available. Pass --company-id, set PAPERCLIP_COMPANY_ID, set a context profile companyId, or authenticate with an agent API key.",
);
}
throw error;
}
const fromAgent = agent?.companyId?.trim();
if (fromAgent) return fromAgent;
throw new Error(
"Current company is not available. Pass --company-id, set PAPERCLIP_COMPANY_ID, set a context profile companyId, or authenticate with an agent API key.",
);
}
function isBoardAccessRequiredError(error: unknown): error is ApiRequestError {
return error instanceof ApiRequestError && error.status === 403 && error.message.toLowerCase().includes("board access required");
}
function isInstanceAdminRequiredError(error: unknown): error is ApiRequestError {
return error instanceof ApiRequestError && error.status === 403 && error.message.toLowerCase().includes("instance admin");
}
function addCompanyJsonPost(parent: Command, name: string, description: string, pathSuffix: string): void {
addCommonClientOptions(
parent
+720
View File
@@ -0,0 +1,720 @@
import { Command } from "commander";
import type {
Approval,
CatalogTeam,
CatalogTeamImportPreviewResult,
CatalogTeamInstallResult,
CatalogTeamInstallOptions,
CatalogTeamImportOptions,
CatalogTeamSourcePolicy,
InstalledCatalogTeam,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
type ResolvedClientContext,
} from "./common.js";
import { ApiRequestError } from "../../client/http.js";
interface TeamBrowseOptions extends BaseClientOptions {
kind?: string;
category?: string;
query?: string;
}
interface TeamListOptions extends TeamBrowseOptions {}
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 {
requestApprovalOnForbidden?: boolean;
approvalIssueId?: string;
secretValue?: string[];
adapterOverride?: string[];
}
interface TeamInstallApprovalFallbackResult {
status: "approval_requested";
approval: Approval;
installAttempt: {
companyId: string;
catalogRef: string;
options: CatalogTeamInstallOptions;
deniedReason: string;
};
}
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("list")
.description("List app-shipped catalog teams with installed status for a company")
.option("--kind <kind>", "Catalog kind filter (bundled or optional)")
.option("--category <slug>", "Catalog category filter")
.option("--query <text>", "Search catalog text")
.action(async (opts: TeamListOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const rows = await listCatalogTeamStatusRows(ctx, opts);
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
printCatalogTeamStatusRows(rows);
} catch (err) {
handleCommandError(err);
}
}),
{ includeCompany: true },
);
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("--secret-value <key=value>", "Secret env input value for install; may be repeated", collectOptionValue, [] as string[])
.option("--adapter-override <slug=type>", "Adapter type override for an imported agent slug; 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)
.option(
"--request-approval-on-forbidden",
"When install is denied by agents:create permissions, create a board approval request instead of exiting with the raw 403",
false,
)
.option("--approval-issue-id <id>", "Issue ID to link to the fallback approval request; defaults to PAPERCLIP_TASK_ID when set")
.action(async (catalogRef: string, opts: TeamInstallOptions) => {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const installOptions = buildTeamInstallOptions(opts);
const result = await ctx.api.post<CatalogTeamInstallResult>(
catalogTeamCompanyPath(ctx.companyId, catalogRef, "install"),
installOptions,
);
if (ctx.json) {
printOutput(result, { json: true });
return;
}
printCatalogTeamInstall(result);
} catch (err) {
if (shouldRequestInstallApproval(err, opts)) {
try {
const ctx = resolveCommandContext(opts, { requireCompany: true });
const fallback = await requestInstallApproval(ctx, catalogRef, buildTeamInstallOptions(opts), opts, err);
if (ctx.json) {
printOutput(fallback, { json: true });
return;
}
printInstallApprovalRequested(fallback);
return;
} catch (fallbackErr) {
handleCommandError(fallbackErr);
}
}
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}` : ""}`)) ?? [];
}
type CatalogTeamInstalledStatus = "not_installed" | "installed" | "out_of_date" | "installed_missing";
interface CatalogTeamStatusRow {
catalogId: string;
catalogKey: string | null;
kind: CatalogTeam["kind"] | null;
category: string | null;
slug: string | null;
name: string;
installedStatus: CatalogTeamInstalledStatus;
installedAgentCount: number;
catalogAgentCount: number | null;
projectCount: number | null;
trustLevel: CatalogTeam["trustLevel"] | null;
present: boolean;
outOfDate: boolean;
currentContentHash: string | null;
installedOriginHashes: string[];
}
async function listCatalogTeamStatusRows(
ctx: ResolvedClientContext,
opts: TeamListOptions,
): Promise<CatalogTeamStatusRow[]> {
if (!ctx.companyId) {
throw new Error("Company ID is required.");
}
const [teams, installed] = await Promise.all([
listCatalogTeams(ctx, opts),
ctx.api.get<InstalledCatalogTeam[]>(
`/api/companies/${encodeURIComponent(ctx.companyId)}/teams/catalog/installed`,
),
]);
const installedByCatalogId = new Map((installed ?? []).map((row) => [row.catalogId, row]));
const rows = teams.map((team) => {
const installedTeam = installedByCatalogId.get(team.id);
if (installedTeam) {
installedByCatalogId.delete(team.id);
}
return buildCatalogTeamStatusRow(team, installedTeam ?? null);
});
for (const installedTeam of installedByCatalogId.values()) {
if (installedTeam.present) continue;
rows.push(buildMissingInstalledTeamStatusRow(installedTeam));
}
return rows;
}
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/${encodeURIComponent(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 removeUndefined({
...buildTeamOptions(opts),
adapterOverrides: parseAdapterOverrides(opts.adapterOverride),
secretValues: parseSecretValues(opts.secretValue),
});
}
const INSTALL_APPROVAL_FALLBACK_MESSAGES = [
"missing permission: agents:create",
"missing permission: can create agents",
];
const SECRET_VALUE_REDACTION = "[redacted]";
function shouldRequestInstallApproval(error: unknown, opts: TeamInstallOptions): error is ApiRequestError {
if (!(opts.requestApprovalOnForbidden || isPaperclipTaskRun())) return false;
if (!(error instanceof ApiRequestError) || error.status !== 403) return false;
const message = error.message.toLowerCase();
return INSTALL_APPROVAL_FALLBACK_MESSAGES.some((expected) => message.includes(expected));
}
function isPaperclipTaskRun(): boolean {
return Boolean(process.env.PAPERCLIP_TASK_ID?.trim());
}
async function requestInstallApproval(
ctx: ResolvedClientContext,
catalogRef: string,
installOptions: CatalogTeamInstallOptions,
opts: TeamInstallOptions,
error: ApiRequestError,
): Promise<TeamInstallApprovalFallbackResult> {
if (!ctx.companyId) throw new Error("Company ID is required.");
const trimmedRef = catalogRef.trim();
const issueIds = resolveApprovalIssueIds(opts);
const approvalInstallOptions = omitInstallSecretValues(installOptions);
const returnedInstallOptions = redactInstallSecretValues(installOptions);
const payload = {
type: "request_board_approval",
issueIds,
payload: {
title: `Approve catalog team install: ${trimmedRef}`,
summary:
`A Paperclip CLI agent-run attempted to install catalog team "${trimmedRef}" into company "${ctx.companyId}", ` +
`but the API denied the install with: ${error.message}.`,
recommendedAction:
"Approve the catalog team source and rerun the install with a board or agent-creator token, or grant agents:create to the requesting agent and rerun the same command.",
risks: [
"Catalog team installation can create agents, projects, tasks, routines, skills, and secret bindings.",
"Only approve after checking the catalog source, selected files, target manager, and collision strategy.",
],
installAttempt: {
companyId: ctx.companyId,
catalogRef: trimmedRef,
options: approvalInstallOptions,
deniedReason: error.message,
},
},
};
const approval = await ctx.api.post<Approval>(apiPath`/api/companies/${ctx.companyId}/approvals`, payload);
if (!approval) {
throw new Error("Approval request failed.");
}
return {
status: "approval_requested",
approval,
installAttempt: {
companyId: ctx.companyId,
catalogRef: trimmedRef,
options: returnedInstallOptions,
deniedReason: error.message,
},
};
}
function omitInstallSecretValues(options: CatalogTeamInstallOptions): CatalogTeamInstallOptions {
if (!options.secretValues) return options;
const { secretValues: _secretValues, ...safeOptions } = options;
return safeOptions;
}
function redactInstallSecretValues(options: CatalogTeamInstallOptions): CatalogTeamInstallOptions {
if (!options.secretValues) return options;
return {
...options,
secretValues: Object.fromEntries(
Object.keys(options.secretValues).map((key) => [key, SECRET_VALUE_REDACTION]),
),
};
}
function resolveApprovalIssueIds(opts: TeamInstallOptions): string[] | undefined {
const issueId = opts.approvalIssueId?.trim() || process.env.PAPERCLIP_TASK_ID?.trim();
if (!issueId) return undefined;
return isUuidLike(issueId) ? [issueId] : undefined;
}
function isUuidLike(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
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 [slug, name] = parseKeyValueOption(raw, "--name-override", "slug=name");
if (!slug || !name) {
throw new Error(`Invalid --name-override "${raw}". Use slug=name.`);
}
result[slug] = name;
}
return result;
}
function parseSecretValues(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 [key, value] = parseKeyValueOption(raw, "--secret-value", "key=value");
if (!key) {
throw new Error(`Invalid --secret-value "${raw}". Use key=value.`);
}
result[key] = value;
}
return result;
}
function parseAdapterOverrides(
values: string[] | undefined,
): CatalogTeamInstallOptions["adapterOverrides"] | undefined {
if (!values || values.length === 0) return undefined;
const result: NonNullable<CatalogTeamInstallOptions["adapterOverrides"]> = {};
for (const raw of values) {
const [slug, adapterType] = parseKeyValueOption(raw, "--adapter-override", "slug=type");
if (!slug || !adapterType) {
throw new Error(`Invalid --adapter-override "${raw}". Use slug=type.`);
}
result[slug] = { adapterType };
}
return result;
}
function parseKeyValueOption(raw: string, flag: string, format: string): [string, string] {
const separator = raw.indexOf("=");
if (separator <= 0) {
throw new Error(`Invalid ${flag} "${raw}". Use ${format}.`);
}
return [raw.slice(0, separator).trim(), raw.slice(separator + 1).trim()];
}
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 buildCatalogTeamStatusRow(
team: CatalogTeam,
installed: InstalledCatalogTeam | null,
): CatalogTeamStatusRow {
return {
catalogId: team.id,
catalogKey: team.key,
kind: team.kind,
category: team.category,
slug: team.slug,
name: team.name,
installedStatus: installed ? (installed.outOfDate ? "out_of_date" : "installed") : "not_installed",
installedAgentCount: installed?.agentCount ?? 0,
catalogAgentCount: team.counts.agents,
projectCount: team.counts.projects,
trustLevel: team.trustLevel,
present: true,
outOfDate: installed?.outOfDate ?? false,
currentContentHash: team.contentHash,
installedOriginHashes: installed?.installedOriginHashes ?? [],
};
}
function buildMissingInstalledTeamStatusRow(installed: InstalledCatalogTeam): CatalogTeamStatusRow {
const name = installed.catalogKey ?? installed.catalogId;
return {
catalogId: installed.catalogId,
catalogKey: installed.catalogKey,
kind: null,
category: null,
slug: null,
name,
installedStatus: "installed_missing",
installedAgentCount: installed.agentCount,
catalogAgentCount: null,
projectCount: null,
trustLevel: null,
present: false,
outOfDate: false,
currentContentHash: installed.currentContentHash,
installedOriginHashes: installed.installedOriginHashes,
};
}
function printCatalogTeamStatusRows(rows: CatalogTeamStatusRow[]): void {
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
printTable(rows.map((row) => ({
id: row.catalogId,
key: row.catalogKey,
kind: row.kind,
category: row.category,
slug: row.slug,
name: row.name,
installedStatus: row.installedStatus,
installedAgents: row.installedAgentCount,
catalogAgents: row.catalogAgentCount,
projects: row.projectCount,
trust: row.trustLevel,
})));
}
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 printInstallApprovalRequested(result: TeamInstallApprovalFallbackResult): void {
console.log(
formatInlineRecord({
status: result.status,
approvalId: result.approval.id,
approvalStatus: result.approval.status,
type: result.approval.type,
catalogRef: result.installAttempt.catalogRef,
deniedReason: result.installAttempt.deniedReason,
}),
);
console.log("Install was not performed. The board must approve the request and rerun the install with an authorized token.");
}
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);