mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 16:35:48 +01:00
PAP-10287 finalize teams catalog extraction
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7b9b79860e
commit
515e628642
13 files changed
+793
-53
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user