PAP-10287 finalize teams catalog extraction

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-06-05 03:00:20 +00:00
1 parent 7b9b79860e
commit 515e628642
13 files changed
+793 -53

No files matched your search

+188 -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(
+149
View File
@@ -353,6 +353,155 @@ describe("teams CLI commands", () => {
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",
"--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",
}),
}),
);
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",
},
});
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("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.',
+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.includes("Board access required");
}
function isInstanceAdminRequiredError(error: unknown): error is ApiRequestError {
return error instanceof ApiRequestError && error.status === 403 && error.message.includes("Instance admin");
}
function addCompanyJsonPost(parent: Command, name: string, description: string, pathSuffix: string): void {
addCommonClientOptions(
parent
+124 -2
View File
@@ -1,5 +1,6 @@
import { Command } from "commander";
import type {
Approval,
CatalogTeam,
CatalogTeamImportPreviewResult,
CatalogTeamInstallResult,
@@ -10,6 +11,7 @@ import type {
} from "@paperclipai/shared";
import {
addCommonClientOptions,
apiPath,
formatInlineRecord,
handleCommandError,
printOutput,
@@ -17,6 +19,7 @@ import {
type BaseClientOptions,
type ResolvedClientContext,
} from "./common.js";
import { ApiRequestError } from "../../client/http.js";
interface TeamBrowseOptions extends BaseClientOptions {
kind?: string;
@@ -39,7 +42,21 @@ interface TeamPreviewOptions extends BaseClientOptions {
allowLocalPathSources?: boolean;
}
interface TeamInstallOptions extends TeamPreviewOptions {}
interface TeamInstallOptions extends TeamPreviewOptions {
requestApprovalOnForbidden?: boolean;
approvalIssueId?: 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");
@@ -192,12 +209,19 @@ export function registerTeamCommands(program: Command): void {
.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"),
buildTeamInstallOptions(opts),
installOptions,
);
if (ctx.json) {
printOutput(result, { json: true });
@@ -205,6 +229,20 @@ export function registerTeamCommands(program: Command): void {
}
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);
}
}),
@@ -323,6 +361,76 @@ function buildTeamInstallOptions(opts: TeamInstallOptions): CatalogTeamInstallOp
return buildTeamOptions(opts);
}
function shouldRequestInstallApproval(error: unknown, opts: TeamInstallOptions): error is ApiRequestError {
return Boolean(
(opts.requestApprovalOnForbidden || isPaperclipTaskRun()) &&
error instanceof ApiRequestError &&
error.status === 403,
);
}
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 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: installOptions,
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: installOptions,
deniedReason: error.message,
},
};
}
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,
@@ -505,6 +613,20 @@ function printCatalogTeamInstall(result: CatalogTeamInstallResult | null): void
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 });
+18
View File
@@ -119,6 +119,7 @@ export PAPERCLIP_API_KEY=...
```sh
pnpm paperclipai company list
pnpm paperclipai company get <company-id>
pnpm paperclipai company current [--company-id <company-id>]
pnpm paperclipai company stats
pnpm paperclipai company create --payload-json '{...}'
pnpm paperclipai company update <company-id> --payload-json '{...}'
@@ -142,6 +143,12 @@ pnpm paperclipai company delete 5cbe79ee-acb3-4597-896e-7662742593cd --yes --con
Notes:
- With agent authentication, `company list` and `company current` are
agent-safe company selectors. `company list` first tries the board-wide list;
if that is forbidden, it uses `--company-id`, `PAPERCLIP_COMPANY_ID`, context,
or `/api/agents/me` and then reads only that scoped company.
- `company create` requires board/instance-admin authentication because it is
an instance-wide setup command.
- Deletion is server-gated by `PAPERCLIP_ENABLE_COMPANY_DELETION`.
- With agent authentication, company deletion is company-scoped. Use the current company ID/prefix (for example via `--company-id` or `PAPERCLIP_COMPANY_ID`), not another company.
@@ -510,6 +517,17 @@ pnpm paperclipai teams install <catalog-id-or-key-or-slug> --company-id <company
Preview/install options:
- Under agent authentication, use `paperclipai company list --json`,
`paperclipai company current --json`, or `PAPERCLIP_COMPANY_ID` to select the
target company. `company list` falls back to the scoped current company when
board-wide listing is forbidden. `teams install` creates agents and therefore
requires board authentication, an `agents:create` grant, or an agent with
explicit `canCreateAgents` permission.
- `--request-approval-on-forbidden` turns a 403 install denial into a linked
board approval request instead of a raw failed command; use
`--approval-issue-id <id>` to attach it to a specific issue. During Paperclip
task runs with `PAPERCLIP_TASK_ID` set, this fallback is automatic so
agent-run walkthroughs leave a pending approval path instead of a raw 403.
- `--target-manager-agent-id <id>` or `--target-manager-slug <slug>` reparents
catalog root agents under an existing manager.
- `--agent <slug>` and `--selected-file <path>` narrow the import.
+8
View File
@@ -35,6 +35,7 @@ pnpm paperclipai issue release <issue-id>
```sh
pnpm paperclipai company list
pnpm paperclipai company get <company-id>
pnpm paperclipai company current [--company-id <company-id>]
# Export to portable folder package (writes manifest + markdown files)
pnpm paperclipai company export <company-id> --out ./exports/acme --include company,agents
@@ -56,6 +57,13 @@ pnpm paperclipai company import \
--include company,agents
```
With agent authentication, use `company list` or `company current` to resolve
the scoped company. `company list` first tries the board-wide list; if that is
forbidden, it falls back to `--company-id`, `PAPERCLIP_COMPANY_ID`, context, or
`/api/agents/me` and returns only that scoped company. `company create` requires
board/instance-admin authentication because it is an instance-wide setup
command.
## Agent Commands
```sh
@@ -0,0 +1,5 @@
schema: paperclip/v1
agents:
cto:
permissions:
canCreateAgents: true
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"packageName": "@paperclipai/teams-catalog",
"packageVersion": "0.1.0",
"generatedAt": "2026-06-03T19:51:52.952Z",
"generatedAt": "2026-06-04T18:03:41.262Z",
"teams": [
{
"id": "paperclipai:bundled:company-defaults:core-exec-team",
@@ -334,6 +334,12 @@
"sizeBytes": 2583,
"sha256": "7204eeef78a48714dd3f82b82e171a4039adc6325e91a6bdf51a442105ee33b0"
},
{
"path": ".paperclip.yaml",
"kind": "extension",
"sizeBytes": 81,
"sha256": "54babf05ba130ec54a143abf01581d99c0ac2936fd0d929bf0e0edd00e29d861"
},
{
"path": "agents/cto/AGENTS.md",
"kind": "agent",
@@ -365,9 +371,9 @@
"sha256": "6acf3ed2da3dfa48de163810b4bc9d385dad537fd34037ca881eec713986a2a8"
}
],
"trustLevel": "markdown_only",
"trustLevel": "assets",
"compatibility": "compatible",
"contentHash": "sha256:c1690ae90dc2049f5e3c7cd60f3206c2d2584d4119197b216aae2c49ec50deb9"
"contentHash": "sha256:74ddeaeb11805835a4a67fc4530b048ab7f8aa1dc7d2b80acea139a933d158d3"
},
{
"id": "paperclipai:optional:content:content-machine",
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import {
defaultPermissionsForRole,
normalizeAgentPermissions,
} from "../services/agent-permissions.js";
describe("agent permissions service", () => {
it("keeps agent-creation authority least-privileged by default", () => {
expect(defaultPermissionsForRole("ceo").canCreateAgents).toBe(true);
expect(defaultPermissionsForRole("CTO").canCreateAgents).toBe(false);
expect(defaultPermissionsForRole("engineering-manager").canCreateAgents).toBe(false);
expect(defaultPermissionsForRole("engineer").canCreateAgents).toBe(false);
});
it("preserves explicit canCreateAgents overrides", () => {
expect(normalizeAgentPermissions({ canCreateAgents: false }, "cto").canCreateAgents).toBe(false);
expect(normalizeAgentPermissions({ canCreateAgents: true }, "engineer").canCreateAgents).toBe(true);
});
});
@@ -58,6 +58,7 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides
name: agents.name,
role: agents.role,
adapterType: agents.adapterType,
permissions: agents.permissions,
})
.from(agents)
.where(eq(agents.companyId, companyId));
@@ -112,6 +113,7 @@ describeEmbeddedPostgres("teams catalog install with no caller adapter overrides
const adapterTypes = Array.from(byName.values()).map((row) => row.adapterType);
expect(adapterTypes).toEqual(["claude_local", "claude_local", "claude_local"]);
expect(adapterTypes).not.toContain("process");
expect(byName.get("CTO")?.permissions).toMatchObject({ canCreateAgents: true });
});
it("honors an explicit caller adapter override for a single slug while defaulting the rest to claude_local", async () => {
@@ -128,6 +128,31 @@ describe("teamsCatalogService", () => {
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"ceo\""));
});
it("preserves package-declared Paperclip sidecar permissions while adding generated catalog provenance", async () => {
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering");
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("permissions:"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("canCreateAgents: true"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogTeam:"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogSlug: \"product-engineering\""));
});
it("preserves package sidecar permissions when generated target-manager metadata is merged onto the same root agent", async () => {
const svc = teamsCatalogService({} as any);
const prepared = await svc.prepareCatalogTeamSource("company-1", "product-engineering", {
targetManagerAgentId: "manager-1",
});
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("permissions:"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("canCreateAgents: true"));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentId: \"manager-1\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("reportsToExistingAgentSlug: \"engineering-manager\""));
expect(prepared.source.files[".paperclip.yaml"]).toEqual(expect.stringContaining("catalogSlug: \"product-engineering\""));
});
it("rejects missing target-manager slugs instead of emitting unresolved reparent metadata", async () => {
mockAgentService.list.mockResolvedValue([]);
const svc = teamsCatalogService({} as any);
+1 -1
View File
@@ -4,7 +4,7 @@ export type NormalizedAgentPermissions = Record<string, unknown> & {
export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions {
return {
canCreateAgents: role === "ceo",
canCreateAgents: role.trim().toLowerCase() === "ceo",
};
}
+159 -44
View File
@@ -344,6 +344,59 @@ function renderStringArrayYaml(key: string, values: string[]) {
];
}
function renderYamlBlock(value: unknown, indentLevel = 0): string[] {
const indent = " ".repeat(indentLevel);
if (Array.isArray(value)) {
if (value.length === 0) return [`${indent}[]`];
const lines: string[] = [];
for (const entry of value) {
if (
entry === null ||
typeof entry === "string" ||
typeof entry === "number" ||
typeof entry === "boolean" ||
(Array.isArray(entry) && entry.length === 0) ||
(isPlainRecord(entry) && Object.keys(entry).length === 0)
) {
lines.push(`${indent}- ${yamlScalar(entry as string | number | boolean | null)}`);
continue;
}
lines.push(`${indent}-`);
lines.push(...renderYamlBlock(entry, indentLevel + 1));
}
return lines;
}
if (isPlainRecord(value)) {
const entries = Object.entries(value).filter(([, entry]) => entry !== undefined);
if (entries.length === 0) return [`${indent}{}`];
const lines: string[] = [];
for (const [key, entry] of entries) {
if (
entry === null ||
typeof entry === "string" ||
typeof entry === "number" ||
typeof entry === "boolean" ||
(Array.isArray(entry) && entry.length === 0) ||
(isPlainRecord(entry) && Object.keys(entry).length === 0)
) {
lines.push(`${indent}${key}: ${yamlScalar(entry as string | number | boolean | null)}`);
continue;
}
lines.push(`${indent}${key}:`);
lines.push(...renderYamlBlock(entry, indentLevel + 1));
}
return lines;
}
return [`${indent}${yamlScalar(String(value))}`];
}
function renderYamlFile(value: Record<string, unknown>) {
return `${renderYamlBlock(value).join("\n")}\n`;
}
function renderSyntheticCompanyMarkdown(team: CatalogTeam) {
const lines = [
"---",
@@ -386,43 +439,61 @@ function renderCatalogProvenanceYaml(team: CatalogTeam, targetManager: CatalogTa
.map((file) => normalizeAgentUrlKey(path.posix.basename(path.posix.dirname(file.path))))
.filter((slug): slug is string => Boolean(slug));
const renderEntity = (slug: string, opts?: { reparentRoot?: boolean }) => [
` ${slug}:`,
...(opts?.reparentRoot && targetManager ? [
` reportsToExistingAgentId: ${yamlScalar(targetManager.agentId)}`,
` reportsToExistingAgentSlug: ${yamlScalar(targetManager.slug)}`,
] : []),
" metadata:",
" paperclip:",
" catalogTeam:",
` catalogId: ${yamlScalar(provenance.catalogId)}`,
` catalogKey: ${yamlScalar(provenance.catalogKey)}`,
` catalogKind: ${yamlScalar(provenance.catalogKind)}`,
` catalogCategory: ${yamlScalar(provenance.catalogCategory)}`,
` catalogSlug: ${yamlScalar(provenance.catalogSlug)}`,
` packageName: ${yamlScalar(provenance.packageName)}`,
` packageVersion: ${yamlScalar(provenance.packageVersion)}`,
` originHash: ${yamlScalar(provenance.originHash)}`,
];
const renderEntity = (slug: string, opts?: { reparentRoot?: boolean }) => ({
...(opts?.reparentRoot && targetManager
? {
reportsToExistingAgentId: targetManager.agentId,
reportsToExistingAgentSlug: targetManager.slug,
}
: {}),
metadata: {
paperclip: {
catalogTeam: {
catalogId: provenance.catalogId,
catalogKey: provenance.catalogKey,
catalogKind: provenance.catalogKind,
catalogCategory: provenance.catalogCategory,
catalogSlug: provenance.catalogSlug,
packageName: provenance.packageName,
packageVersion: provenance.packageVersion,
originHash: provenance.originHash,
},
},
},
});
const lines = [
"schema: paperclip/v1",
"agents:",
...agentSlugs.flatMap((slug) =>
const extension: Record<string, unknown> = {
schema: "paperclip/v1",
agents: Object.fromEntries(agentSlugs.map((slug) => [
slug,
renderEntity(slug, {
reparentRoot: Boolean(targetManager && team.rootAgentSlugs.includes(slug)),
}),
),
];
])),
};
if (projectSlugs.length > 0) {
lines.push("projects:");
lines.push(...projectSlugs.flatMap((slug) => renderEntity(slug)));
extension.projects = Object.fromEntries(projectSlugs.map((slug) => [slug, renderEntity(slug)]));
}
if (taskSlugs.length > 0) {
lines.push("tasks:");
lines.push(...Array.from(new Set(taskSlugs)).sort().flatMap((slug) => renderEntity(slug)));
extension.tasks = Object.fromEntries(Array.from(new Set(taskSlugs)).sort().map((slug) => [slug, renderEntity(slug)]));
}
return `${lines.join("\n")}\n`;
return renderYamlFile(extension);
}
function mergePlainRecords(
base: Record<string, unknown>,
override: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
const existing = merged[key];
if (isPlainRecord(existing) && isPlainRecord(value)) {
merged[key] = mergePlainRecords(existing, value);
continue;
}
merged[key] = value;
}
return merged;
}
interface MarkdownDoc {
@@ -457,30 +528,69 @@ function parseYamlFrontmatter(raw: string): Record<string, unknown> {
content: line.trim(),
}))
.filter((line) => line.content.length > 0 && !line.content.startsWith("#"));
if (lines.length === 0) return {};
const parsed = parseYamlBlock(lines, 0, lines[0]!.indent);
return isPlainRecord(parsed.value) ? parsed.value : {};
}
function parseYamlBlock(
lines: Array<{ indent: number; content: string }>,
startIndex: number,
indentLevel: number,
): { value: unknown; nextIndex: number } {
let index = startIndex;
if (index >= lines.length || lines[index]!.indent < indentLevel) {
return { value: {}, nextIndex: index };
}
const isArray = lines[index]!.indent === indentLevel && lines[index]!.content.startsWith("-");
if (isArray) {
const values: unknown[] = [];
while (index < lines.length) {
const line = lines[index]!;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel || !line.content.startsWith("-")) break;
const remainder = line.content.slice(1).trim();
index += 1;
if (!remainder) {
const nested = parseYamlBlock(lines, index, indentLevel + 2);
values.push(nested.value);
index = nested.nextIndex;
continue;
}
values.push(parseYamlScalar(remainder));
}
return { value: values, nextIndex: index };
}
const record: Record<string, unknown> = {};
for (let index = 0; index < lines.length; index += 1) {
while (index < lines.length) {
const line = lines[index]!;
if (line.indent !== 0) continue;
if (line.indent < indentLevel) break;
if (line.indent !== indentLevel) {
index += 1;
continue;
}
const separatorIndex = line.content.indexOf(":");
if (separatorIndex <= 0) continue;
if (separatorIndex <= 0) {
index += 1;
continue;
}
const key = line.content.slice(0, separatorIndex).trim();
const remainder = line.content.slice(separatorIndex + 1).trim();
index += 1;
if (remainder) {
record[key] = parseYamlScalar(remainder);
continue;
}
const values: string[] = [];
while (index + 1 < lines.length && lines[index + 1]!.indent > line.indent) {
const next = lines[index + 1]!;
if (next.indent === line.indent + 2 && next.content.startsWith("-")) {
const value = next.content.slice(1).trim();
if (value) values.push(String(parseYamlScalar(value)));
}
index += 1;
}
record[key] = values;
const nested = parseYamlBlock(lines, index, indentLevel + 2);
record[key] = nested.value;
index = nested.nextIndex;
}
return record;
return { value: record, nextIndex: index };
}
function parseFrontmatterMarkdown(raw: string): MarkdownDoc {
@@ -765,7 +875,12 @@ export function teamsCatalogService(db: Db) {
const targetManager = await resolveTargetManagerReference(companyId, options);
const files = await readCatalogTeamSourceFiles(team);
files[".paperclip.yaml"] = renderCatalogProvenanceYaml(team, targetManager);
const existingExtension =
typeof files[".paperclip.yaml"] === "string"
? parseYamlFrontmatter(files[".paperclip.yaml"])
: {};
const generatedExtension = parseYamlFrontmatter(renderCatalogProvenanceYaml(team, targetManager));
files[".paperclip.yaml"] = renderYamlFile(mergePlainRecords(existingExtension, generatedExtension));
rewriteAgentCatalogSkillRefs(team, files);
return {