PAP-10287 Reparent catalog tasks and show installed status

This commit is contained in:
Dotta committed 2026-06-05 03:20:50 +00:00
1 parent 372caafae2
commit 159d6699ef
13 files changed
+598 -33

No files matched your search

+110
View File
@@ -56,6 +56,19 @@ function catalogTeam(overrides: Record<string, unknown> = {}) {
};
}
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>;
@@ -130,6 +143,103 @@ describe("teams CLI commands", () => {
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));
+142
View File
@@ -6,6 +6,7 @@ import type {
CatalogTeamInstallOptions,
CatalogTeamImportOptions,
CatalogTeamSourcePolicy,
InstalledCatalogTeam,
} from "@paperclipai/shared";
import {
addCommonClientOptions,
@@ -23,6 +24,8 @@ interface TeamBrowseOptions extends BaseClientOptions {
query?: string;
}
interface TeamListOptions extends TeamBrowseOptions {}
interface TeamPreviewOptions extends BaseClientOptions {
companyId?: string;
targetManagerAgentId?: string;
@@ -63,6 +66,29 @@ export function registerTeamCommands(program: Command): void {
}),
);
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")
@@ -198,6 +224,58 @@ async function listCatalogTeams(
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) {
@@ -310,6 +388,70 @@ function printCatalogTeamRows(rows: CatalogTeam[]): void {
})));
}
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({