feat: company portability — export/import companies and agents

Add company export, import preview, and import endpoints with manifest-
based bundle format. Includes URL key utilities for agents and projects,
collision detection/rename strategies, and secret requirement tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-03-02 09:06:58 -06:00
parent 79a9dffc80
commit cc2c724ad2
10 changed files with 1197 additions and 3 deletions

View File

@@ -0,0 +1,22 @@
const AGENT_URL_KEY_DELIM_RE = /[^a-z0-9]+/g;
const AGENT_URL_KEY_TRIM_RE = /^-+|-+$/g;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isUuidLike(value: string | null | undefined): boolean {
if (typeof value !== "string") return false;
return UUID_RE.test(value.trim());
}
export function normalizeAgentUrlKey(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value
.trim()
.toLowerCase()
.replace(AGENT_URL_KEY_DELIM_RE, "-")
.replace(AGENT_URL_KEY_TRIM_RE, "");
return normalized.length > 0 ? normalized : null;
}
export function deriveAgentUrlKey(name: string | null | undefined, fallback?: string | null): string {
return normalizeAgentUrlKey(name) ?? normalizeAgentUrlKey(fallback) ?? "agent";
}

View File

@@ -101,6 +101,22 @@ export type {
Invite,
JoinRequest,
InstanceUserRoleGrant,
CompanyPortabilityInclude,
CompanyPortabilitySecretRequirement,
CompanyPortabilityCompanyManifestEntry,
CompanyPortabilityAgentManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
CompanyPortabilitySource,
CompanyPortabilityImportTarget,
CompanyPortabilityAgentSelection,
CompanyPortabilityCollisionStrategy,
CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewResult,
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
EnvBinding,
AgentEnvConfig,
CompanySecret,
@@ -193,9 +209,25 @@ export {
type ClaimJoinRequestApiKey,
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
portabilityIncludeSchema,
portabilitySecretRequirementSchema,
portabilityCompanyManifestEntrySchema,
portabilityAgentManifestEntrySchema,
portabilityManifestSchema,
portabilitySourceSchema,
portabilityTargetSchema,
portabilityAgentSelectionSchema,
portabilityCollisionStrategySchema,
companyPortabilityExportSchema,
companyPortabilityPreviewSchema,
companyPortabilityImportSchema,
type CompanyPortabilityExport,
type CompanyPortabilityPreview,
type CompanyPortabilityImport,
} from "./validators/index.js";
export { API_PREFIX, API } from "./api.js";
export { normalizeAgentUrlKey, deriveAgentUrlKey } from "./agent-url-key.js";
export {
paperclipConfigSchema,

View File

@@ -0,0 +1,16 @@
const PROJECT_URL_KEY_DELIM_RE = /[^a-z0-9]+/g;
const PROJECT_URL_KEY_TRIM_RE = /^-+|-+$/g;
export function normalizeProjectUrlKey(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const normalized = value
.trim()
.toLowerCase()
.replace(PROJECT_URL_KEY_DELIM_RE, "-")
.replace(PROJECT_URL_KEY_TRIM_RE, "");
return normalized.length > 0 ? normalized : null;
}
export function deriveProjectUrlKey(name: string | null | undefined, fallback?: string | null): string {
return normalizeProjectUrlKey(name) ?? normalizeProjectUrlKey(fallback) ?? "project";
}

View File

@@ -0,0 +1,138 @@
export interface CompanyPortabilityInclude {
company: boolean;
agents: boolean;
}
export interface CompanyPortabilitySecretRequirement {
key: string;
description: string | null;
agentSlug: string | null;
providerHint: string | null;
}
export interface CompanyPortabilityCompanyManifestEntry {
path: string;
name: string;
description: string | null;
brandColor: string | null;
requireBoardApprovalForNewAgents: boolean;
}
export interface CompanyPortabilityAgentManifestEntry {
slug: string;
name: string;
path: string;
role: string;
title: string | null;
icon: string | null;
capabilities: string | null;
reportsToSlug: string | null;
adapterType: string;
adapterConfig: Record<string, unknown>;
runtimeConfig: Record<string, unknown>;
permissions: Record<string, unknown>;
budgetMonthlyCents: number;
metadata: Record<string, unknown> | null;
}
export interface CompanyPortabilityManifest {
schemaVersion: number;
generatedAt: string;
source: {
companyId: string;
companyName: string;
} | null;
includes: CompanyPortabilityInclude;
company: CompanyPortabilityCompanyManifestEntry | null;
agents: CompanyPortabilityAgentManifestEntry[];
requiredSecrets: CompanyPortabilitySecretRequirement[];
}
export interface CompanyPortabilityExportResult {
manifest: CompanyPortabilityManifest;
files: Record<string, string>;
warnings: string[];
}
export type CompanyPortabilitySource =
| {
type: "inline";
manifest: CompanyPortabilityManifest;
files: Record<string, string>;
}
| {
type: "url";
url: string;
}
| {
type: "github";
url: string;
};
export type CompanyPortabilityImportTarget =
| {
mode: "new_company";
newCompanyName?: string | null;
}
| {
mode: "existing_company";
companyId: string;
};
export type CompanyPortabilityAgentSelection = "all" | string[];
export type CompanyPortabilityCollisionStrategy = "rename" | "skip" | "replace";
export interface CompanyPortabilityPreviewRequest {
source: CompanyPortabilitySource;
include?: Partial<CompanyPortabilityInclude>;
target: CompanyPortabilityImportTarget;
agents?: CompanyPortabilityAgentSelection;
collisionStrategy?: CompanyPortabilityCollisionStrategy;
}
export interface CompanyPortabilityPreviewAgentPlan {
slug: string;
action: "create" | "update" | "skip";
plannedName: string;
existingAgentId: string | null;
reason: string | null;
}
export interface CompanyPortabilityPreviewResult {
include: CompanyPortabilityInclude;
targetCompanyId: string | null;
targetCompanyName: string | null;
collisionStrategy: CompanyPortabilityCollisionStrategy;
selectedAgentSlugs: string[];
plan: {
companyAction: "none" | "create" | "update";
agentPlans: CompanyPortabilityPreviewAgentPlan[];
};
requiredSecrets: CompanyPortabilitySecretRequirement[];
warnings: string[];
errors: string[];
}
export interface CompanyPortabilityImportRequest extends CompanyPortabilityPreviewRequest {}
export interface CompanyPortabilityImportResult {
company: {
id: string;
name: string;
action: "created" | "updated" | "unchanged";
};
agents: {
slug: string;
id: string | null;
action: "created" | "updated" | "skipped";
name: string;
reason: string | null;
}[];
requiredSecrets: CompanyPortabilitySecretRequirement[];
warnings: string[];
}
export interface CompanyPortabilityExportRequest {
include?: Partial<CompanyPortabilityInclude>;
}

View File

@@ -52,3 +52,21 @@ export type {
JoinRequest,
InstanceUserRoleGrant,
} from "./access.js";
export type {
CompanyPortabilityInclude,
CompanyPortabilitySecretRequirement,
CompanyPortabilityCompanyManifestEntry,
CompanyPortabilityAgentManifestEntry,
CompanyPortabilityManifest,
CompanyPortabilityExportResult,
CompanyPortabilitySource,
CompanyPortabilityImportTarget,
CompanyPortabilityAgentSelection,
CompanyPortabilityCollisionStrategy,
CompanyPortabilityPreviewRequest,
CompanyPortabilityPreviewAgentPlan,
CompanyPortabilityPreviewResult,
CompanyPortabilityImportRequest,
CompanyPortabilityImportResult,
CompanyPortabilityExportRequest,
} from "./company-portability.js";

View File

@@ -0,0 +1,112 @@
import { z } from "zod";
export const portabilityIncludeSchema = z
.object({
company: z.boolean().optional(),
agents: z.boolean().optional(),
})
.partial();
export const portabilitySecretRequirementSchema = z.object({
key: z.string().min(1),
description: z.string().nullable(),
agentSlug: z.string().min(1).nullable(),
providerHint: z.string().nullable(),
});
export const portabilityCompanyManifestEntrySchema = z.object({
path: z.string().min(1),
name: z.string().min(1),
description: z.string().nullable(),
brandColor: z.string().nullable(),
requireBoardApprovalForNewAgents: z.boolean(),
});
export const portabilityAgentManifestEntrySchema = z.object({
slug: z.string().min(1),
name: z.string().min(1),
path: z.string().min(1),
role: z.string().min(1),
title: z.string().nullable(),
icon: z.string().nullable(),
capabilities: z.string().nullable(),
reportsToSlug: z.string().min(1).nullable(),
adapterType: z.string().min(1),
adapterConfig: z.record(z.unknown()),
runtimeConfig: z.record(z.unknown()),
permissions: z.record(z.unknown()),
budgetMonthlyCents: z.number().int().nonnegative(),
metadata: z.record(z.unknown()).nullable(),
});
export const portabilityManifestSchema = z.object({
schemaVersion: z.number().int().positive(),
generatedAt: z.string().datetime(),
source: z
.object({
companyId: z.string().uuid(),
companyName: z.string().min(1),
})
.nullable(),
includes: z.object({
company: z.boolean(),
agents: z.boolean(),
}),
company: portabilityCompanyManifestEntrySchema.nullable(),
agents: z.array(portabilityAgentManifestEntrySchema),
requiredSecrets: z.array(portabilitySecretRequirementSchema).default([]),
});
export const portabilitySourceSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("inline"),
manifest: portabilityManifestSchema,
files: z.record(z.string()),
}),
z.object({
type: z.literal("url"),
url: z.string().url(),
}),
z.object({
type: z.literal("github"),
url: z.string().url(),
}),
]);
export const portabilityTargetSchema = z.discriminatedUnion("mode", [
z.object({
mode: z.literal("new_company"),
newCompanyName: z.string().min(1).optional().nullable(),
}),
z.object({
mode: z.literal("existing_company"),
companyId: z.string().uuid(),
}),
]);
export const portabilityAgentSelectionSchema = z.union([
z.literal("all"),
z.array(z.string().min(1)),
]);
export const portabilityCollisionStrategySchema = z.enum(["rename", "skip", "replace"]);
export const companyPortabilityExportSchema = z.object({
include: portabilityIncludeSchema.optional(),
});
export type CompanyPortabilityExport = z.infer<typeof companyPortabilityExportSchema>;
export const companyPortabilityPreviewSchema = z.object({
source: portabilitySourceSchema,
include: portabilityIncludeSchema.optional(),
target: portabilityTargetSchema,
agents: portabilityAgentSelectionSchema.optional(),
collisionStrategy: portabilityCollisionStrategySchema.optional(),
});
export type CompanyPortabilityPreview = z.infer<typeof companyPortabilityPreviewSchema>;
export const companyPortabilityImportSchema = companyPortabilityPreviewSchema;
export type CompanyPortabilityImport = z.infer<typeof companyPortabilityImportSchema>;

View File

@@ -112,3 +112,21 @@ export {
type UpdateMemberPermissions,
type UpdateUserCompanyAccess,
} from "./access.js";
export {
portabilityIncludeSchema,
portabilitySecretRequirementSchema,
portabilityCompanyManifestEntrySchema,
portabilityAgentManifestEntrySchema,
portabilityManifestSchema,
portabilitySourceSchema,
portabilityTargetSchema,
portabilityAgentSelectionSchema,
portabilityCollisionStrategySchema,
companyPortabilityExportSchema,
companyPortabilityPreviewSchema,
companyPortabilityImportSchema,
type CompanyPortabilityExport,
type CompanyPortabilityPreview,
type CompanyPortabilityImport,
} from "./company-portability.js";