[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:
@@ -0,0 +1,366 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type {
|
||||
Agent,
|
||||
CatalogTeamImportPreviewResult,
|
||||
CompanyPortabilityCollisionStrategy,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
ApplyProgress,
|
||||
ApplySuccess,
|
||||
StepPreview,
|
||||
StepSkillPlan,
|
||||
StepSourcePolicy,
|
||||
StepTargetManager,
|
||||
TeamCard,
|
||||
TeamDetailPane,
|
||||
TeamRow,
|
||||
} from "@/pages/TeamCatalog";
|
||||
import {
|
||||
currentInstalledState,
|
||||
onboardingTeams,
|
||||
optionalTeam,
|
||||
outOfDateInstalledState,
|
||||
sampleTeam as baseTeam,
|
||||
warnTeam,
|
||||
} from "@/pages/TeamCatalog.fixtures";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
//
|
||||
// Team fixtures (baseTeam/optionalTeam/warnTeam) are shared with the in-app
|
||||
// /design-guide showcase via @/pages/TeamCatalog.fixtures so the two surfaces
|
||||
// stay in sync. Preview/agent fixtures below are story-only.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const companyAgents: Agent[] = [
|
||||
makeAgent("agent-1", "Founder", "ceo"),
|
||||
makeAgent("agent-2", "Head of Eng", "engineer"),
|
||||
makeAgent("agent-3", "Ops Lead", "general"),
|
||||
];
|
||||
|
||||
function makeAgent(id: string, name: string, role: string): Agent {
|
||||
return {
|
||||
id,
|
||||
companyId: "company-storybook",
|
||||
name,
|
||||
urlKey: name.toLowerCase().replace(/\s+/g, "-"),
|
||||
role: role as Agent["role"],
|
||||
title: null,
|
||||
icon: null,
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "claude_local" as Agent["adapterType"],
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {} as Agent["runtimeConfig"],
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: {} as Agent["permissions"],
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
};
|
||||
}
|
||||
|
||||
function makePreview(errors: string[] = []): CatalogTeamImportPreviewResult {
|
||||
return {
|
||||
team: baseTeam,
|
||||
portabilityPreview: {
|
||||
include: { company: false, agents: true, projects: true, issues: false, skills: true },
|
||||
targetCompanyId: "company-storybook",
|
||||
targetCompanyName: "Paperclip",
|
||||
collisionStrategy: "rename",
|
||||
selectedAgentSlugs: ["ceo", "cto", "cmo"],
|
||||
plan: {
|
||||
companyAction: "none",
|
||||
agentPlans: [
|
||||
{ slug: "ceo", action: "create", plannedName: "CEO", existingAgentId: null, reason: null },
|
||||
{ slug: "cto", action: "create", plannedName: "CTO", existingAgentId: null, reason: null },
|
||||
{ slug: "cmo", action: "create", plannedName: "CMO (from Core Exec Team)", existingAgentId: "agent-x", reason: "Renamed — name collision with existing agent" },
|
||||
],
|
||||
projectPlans: [
|
||||
{ slug: "launch", action: "create", plannedName: "Launch", existingProjectId: null, reason: null },
|
||||
],
|
||||
issuePlans: [
|
||||
{ slug: "kickoff", action: "skip", plannedTitle: "Kickoff", reason: "Starter tasks not selected" },
|
||||
],
|
||||
},
|
||||
manifest: {
|
||||
schemaVersion: 1,
|
||||
generatedAt: "2026-06-03T00:00:00.000Z",
|
||||
source: null,
|
||||
includes: { company: false, agents: true, projects: true, issues: false, skills: true },
|
||||
company: null,
|
||||
sidebar: null,
|
||||
agents: [
|
||||
manifestAgent("ceo", "CEO"),
|
||||
manifestAgent("cto", "CTO"),
|
||||
manifestAgent("cmo", "CMO"),
|
||||
],
|
||||
skills: [],
|
||||
projects: [],
|
||||
issues: [],
|
||||
envInputs: [],
|
||||
},
|
||||
files: {},
|
||||
envInputs: [
|
||||
{ key: "OPENAI_API_KEY", description: "API key for the CTO agent", agentSlug: "cto", projectSlug: null, kind: "secret", requirement: "required", defaultValue: null, portability: "system_dependent" },
|
||||
{ key: "DEFAULT_TIMEZONE", description: "Project timezone", agentSlug: null, projectSlug: "launch", kind: "plain", requirement: "optional", defaultValue: "UTC", portability: "portable" },
|
||||
],
|
||||
warnings: ["Skill acme/growth-playbook will be imported from an external GitHub source."],
|
||||
errors,
|
||||
},
|
||||
skillPreparations: [
|
||||
{ type: "catalog", ref: "engineering/code-review", agentSlugs: ["cto"], action: "already_in_package", catalogSkillId: "skill-1", catalogSkillKey: "engineering/code-review", sourceLocator: null, sourceRef: null, reason: null },
|
||||
{ type: "github", ref: "acme/growth-playbook@v1.2.0", agentSlugs: ["cmo"], action: "external_import_required", catalogSkillId: null, catalogSkillKey: null, sourceLocator: "github.com/acme/growth-playbook", sourceRef: "v1.2.0", reason: "Resolved from GitHub at install time" },
|
||||
],
|
||||
warnings: [],
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function manifestAgent(slug: string, name: string): any {
|
||||
return {
|
||||
slug, name, path: `agents/${slug}/AGENTS.md`, skills: [], role: slug, title: null, icon: null,
|
||||
capabilities: null, reportsToSlug: slug === "ceo" ? null : "ceo", adapterType: "claude_local",
|
||||
adapterConfig: {}, runtimeConfig: {}, permissions: {}, budgetMonthlyCents: 0, metadata: null,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function Frame({ children, width = "max-w-3xl" }: { children: React.ReactNode; width?: string }) {
|
||||
return <div className={`mx-auto w-full ${width} rounded-lg border border-border bg-background p-5`}>{children}</div>;
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: "Surfaces/Team Catalog",
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const BrowseList: Story = {
|
||||
render: () => (
|
||||
<div className="w-[28rem] border border-border">
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Bundled · 1</div>
|
||||
<TeamRow team={baseTeam} selected onSelect={noop} />
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Optional · 2</div>
|
||||
<TeamRow team={optionalTeam} selected={false} onSelect={noop} />
|
||||
<TeamRow team={warnTeam} selected={false} onSelect={noop} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const DetailPane: Story = {
|
||||
render: () => (
|
||||
<div className="h-[760px] overflow-hidden border border-border">
|
||||
<TeamDetailPane
|
||||
team={baseTeam}
|
||||
selectedPath={null}
|
||||
onSelectFile={noop}
|
||||
onInstall={noop}
|
||||
canInstall
|
||||
fileContent={null}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// PAP-10256: installed/out-of-date surface driven by the server signal.
|
||||
export const InstalledStates: Story = {
|
||||
render: () => (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="w-[28rem] border border-border">
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Bundled · 1</div>
|
||||
<TeamRow team={optionalTeam} selected={false} onSelect={noop} />
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Installed · 2</div>
|
||||
<TeamRow team={baseTeam} selected onSelect={noop} installed={outOfDateInstalledState} />
|
||||
<TeamRow team={warnTeam} selected={false} onSelect={noop} installed={currentInstalledState} />
|
||||
</div>
|
||||
<div className="h-[760px] overflow-hidden border border-border">
|
||||
<TeamDetailPane
|
||||
team={baseTeam}
|
||||
selectedPath={null}
|
||||
onSelectFile={noop}
|
||||
onInstall={noop}
|
||||
canInstall
|
||||
fileContent={null}
|
||||
installed={outOfDateInstalledState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InstallTargetManager: Story = {
|
||||
render: function Render() {
|
||||
const [managerId, setManagerId] = useState<string | null>(null);
|
||||
const [fullCompany, setFullCompany] = useState(false);
|
||||
return (
|
||||
<Frame>
|
||||
<StepTargetManager
|
||||
team={baseTeam}
|
||||
agents={companyAgents}
|
||||
targetManagerAgentId={managerId}
|
||||
onPickManager={setManagerId}
|
||||
fullCompany={fullCompany}
|
||||
onToggleFullCompany={setFullCompany}
|
||||
canBypassManager
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InstallSourcePolicy: Story = {
|
||||
render: function Render() {
|
||||
const [ext, setExt] = useState(false);
|
||||
const [unpinned, setUnpinned] = useState(false);
|
||||
const [local, setLocal] = useState(false);
|
||||
return (
|
||||
<Frame>
|
||||
<StepSourcePolicy
|
||||
team={warnTeam}
|
||||
allowExternalSources={ext}
|
||||
allowUnpinnedOptionalSources={unpinned}
|
||||
allowLocalPathSources={local}
|
||||
onChange={(key, v) => {
|
||||
if (key === "external") setExt(v);
|
||||
if (key === "unpinned") setUnpinned(v);
|
||||
if (key === "localPath") setLocal(v);
|
||||
}}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InstallSkillPlan: Story = {
|
||||
render: () => (
|
||||
<Frame>
|
||||
<StepSkillPlan team={baseTeam} preparations={makePreview().skillPreparations} />
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
export const InstallPreview: Story = {
|
||||
render: function Render() {
|
||||
const [collision, setCollision] = useState<CompanyPortabilityCollisionStrategy>("rename");
|
||||
const [names, setNames] = useState<Record<string, string>>({});
|
||||
const [adapters, setAdapters] = useState<Record<string, string>>({});
|
||||
return (
|
||||
<Frame>
|
||||
<StepPreview
|
||||
team={baseTeam}
|
||||
loading={false}
|
||||
error={null}
|
||||
result={makePreview()}
|
||||
collisionStrategy={collision}
|
||||
onCollisionStrategyChange={setCollision}
|
||||
nameOverrides={names}
|
||||
onRename={(slug, name) => setNames((c) => ({ ...c, [slug]: name }))}
|
||||
adapterOverrides={adapters}
|
||||
onAdapterChange={(slug, t) => setAdapters((c) => ({ ...c, [slug]: t }))}
|
||||
onRetry={noop}
|
||||
/>
|
||||
</Frame>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const InstallPreviewBlocked: Story = {
|
||||
render: () => (
|
||||
<Frame>
|
||||
<StepPreview
|
||||
team={baseTeam}
|
||||
loading={false}
|
||||
error={null}
|
||||
result={makePreview(["Target manager is required before this team can be installed.", "Skill acme/growth-playbook is blocked by source policy."])}
|
||||
collisionStrategy="rename"
|
||||
onCollisionStrategyChange={noop}
|
||||
nameOverrides={{}}
|
||||
onRename={noop}
|
||||
adapterOverrides={{}}
|
||||
onAdapterChange={noop}
|
||||
onRetry={noop}
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
export const InstallApplyProgress: Story = {
|
||||
render: () => (
|
||||
<Frame>
|
||||
<ApplyProgress team={baseTeam} />
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
|
||||
// Onboarding seam (design §6 + §12.5): the TeamCard tile rendered in the
|
||||
// 3-col "Pick a starter team" grid, with the first defaultInstall tile selected.
|
||||
export const OnboardingTeamGrid: Story = {
|
||||
render: function Render() {
|
||||
const [selectedId, setSelectedId] = useState(onboardingTeams[0]?.id ?? null);
|
||||
return (
|
||||
<Frame width="max-w-2xl">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">Pick a starter team</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We'll set up agents, projects, and routines so you can start with a working team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{onboardingTeams.map((team) => (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
team={team}
|
||||
selected={team.id === selectedId}
|
||||
onSelect={() => setSelectedId(team.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Frame>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// A single TeamCard in its selected state.
|
||||
export const TeamCardSelected: Story = {
|
||||
render: () => (
|
||||
<div className="mx-auto w-64">
|
||||
<TeamCard team={onboardingTeams[0]} selected onSelect={noop} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const InstallSuccess: Story = {
|
||||
render: () => (
|
||||
<Frame>
|
||||
<ApplySuccess
|
||||
team={baseTeam}
|
||||
result={{
|
||||
team: baseTeam,
|
||||
portabilityImport: {
|
||||
company: { id: "company-storybook", name: "Paperclip", action: "unchanged" },
|
||||
agents: [
|
||||
{ slug: "ceo", id: "a1", action: "created", name: "CEO", reason: null },
|
||||
{ slug: "cto", id: "a2", action: "created", name: "CTO", reason: null },
|
||||
{ slug: "cmo", id: "a3", action: "created", name: "CMO (from Core Exec Team)", reason: null },
|
||||
],
|
||||
projects: [{ slug: "launch", id: "p1", action: "created", name: "Launch", reason: null }],
|
||||
envInputs: [],
|
||||
warnings: [],
|
||||
},
|
||||
skillPreparations: makePreview().skillPreparations,
|
||||
warnings: ["Skill acme/growth-playbook imported from GitHub — review pinned ref."],
|
||||
}}
|
||||
onClose={noop}
|
||||
/>
|
||||
</Frame>
|
||||
),
|
||||
};
|
||||
Reference in New Issue
Block a user