[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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user