Add explicit skill selection to company portability

This commit is contained in:
dotta 2026-03-20 06:20:30 -05:00
parent 14ee364190
commit 5f2b1b63c2
6 changed files with 29 additions and 13 deletions

View file

@ -85,16 +85,17 @@ function normalizeSelector(input: string): string {
} }
function parseInclude(input: string | undefined): CompanyPortabilityInclude { function parseInclude(input: string | undefined): CompanyPortabilityInclude {
if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false }; if (!input || !input.trim()) return { company: true, agents: true, projects: false, issues: false, skills: false };
const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean); const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean);
const include = { const include = {
company: values.includes("company"), company: values.includes("company"),
agents: values.includes("agents"), agents: values.includes("agents"),
projects: values.includes("projects"), projects: values.includes("projects"),
issues: values.includes("issues"), issues: values.includes("issues") || values.includes("tasks"),
skills: values.includes("skills"),
}; };
if (!include.company && !include.agents && !include.projects && !include.issues) { if (!include.company && !include.agents && !include.projects && !include.issues && !include.skills) {
throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues"); throw new Error("Invalid --include value. Use one or more of: company,agents,projects,issues,tasks,skills");
} }
return include; return include;
} }
@ -337,7 +338,7 @@ export function registerCompanyCommands(program: Command): void {
.description("Export a company into a portable markdown package") .description("Export a company into a portable markdown package")
.argument("<companyId>", "Company ID") .argument("<companyId>", "Company ID")
.requiredOption("--out <path>", "Output directory") .requiredOption("--out <path>", "Output directory")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues", "company,agents") .option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
.option("--skills <values>", "Comma-separated skill slugs/keys to export") .option("--skills <values>", "Comma-separated skill slugs/keys to export")
.option("--projects <values>", "Comma-separated project shortnames/ids to export") .option("--projects <values>", "Comma-separated project shortnames/ids to export")
.option("--issues <values>", "Comma-separated issue identifiers/ids to export") .option("--issues <values>", "Comma-separated issue identifiers/ids to export")
@ -390,7 +391,7 @@ export function registerCompanyCommands(program: Command): void {
.command("import") .command("import")
.description("Import a portable markdown company package from local path, URL, or GitHub") .description("Import a portable markdown company package from local path, URL, or GitHub")
.requiredOption("--from <pathOrUrl>", "Source path or URL") .requiredOption("--from <pathOrUrl>", "Source path or URL")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues", "company,agents") .option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
.option("--target <mode>", "Target mode: new | existing") .option("--target <mode>", "Target mode: new | existing")
.option("-C, --company-id <id>", "Existing target company ID") .option("-C, --company-id <id>", "Existing target company ID")
.option("--new-company-name <name>", "Name override for --target new") .option("--new-company-name <name>", "Name override for --target new")

View file

@ -3,6 +3,7 @@ export interface CompanyPortabilityInclude {
agents: boolean; agents: boolean;
projects: boolean; projects: boolean;
issues: boolean; issues: boolean;
skills: boolean;
} }
export interface CompanyPortabilityEnvInput { export interface CompanyPortabilityEnvInput {

View file

@ -6,6 +6,7 @@ export const portabilityIncludeSchema = z
agents: z.boolean().optional(), agents: z.boolean().optional(),
projects: z.boolean().optional(), projects: z.boolean().optional(),
issues: z.boolean().optional(), issues: z.boolean().optional(),
skills: z.boolean().optional(),
}) })
.partial(); .partial();
@ -119,6 +120,7 @@ export const portabilityManifestSchema = z.object({
agents: z.boolean(), agents: z.boolean(),
projects: z.boolean(), projects: z.boolean(),
issues: z.boolean(), issues: z.boolean(),
skills: z.boolean(),
}), }),
company: portabilityCompanyManifestEntrySchema.nullable(), company: portabilityCompanyManifestEntrySchema.nullable(),
agents: z.array(portabilityAgentManifestEntrySchema), agents: z.array(portabilityAgentManifestEntrySchema),

View file

@ -97,7 +97,7 @@ describe("company portability routes", () => {
}); });
mockCompanyPortabilityService.previewExport.mockResolvedValue({ mockCompanyPortabilityService.previewExport.mockResolvedValue({
rootPath: "paperclip", rootPath: "paperclip",
manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null }, manifest: { agents: [], skills: [], projects: [], issues: [], envInputs: [], includes: { company: true, agents: true, projects: true, issues: false, skills: false }, company: null, schemaVersion: 1, generatedAt: new Date().toISOString(), source: null },
files: {}, files: {},
fileInventory: [], fileInventory: [],
counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 }, counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 },

View file

@ -666,7 +666,8 @@ describe("company portability", () => {
collisionStrategy: "rename", collisionStrategy: "rename",
}, "user-1"); }, "user-1");
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, { const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string"));
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, {
onConflict: "replace", onConflict: "replace",
}); });
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
@ -812,7 +813,8 @@ describe("company portability", () => {
expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1"); expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1");
expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported"); expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported");
expect(accessSvc.ensureMembership).not.toHaveBeenCalledWith("company-imported", "user", expect.anything(), "owner", "active"); expect(accessSvc.ensureMembership).not.toHaveBeenCalledWith("company-imported", "user", expect.anything(), "owner", "active");
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", exported.files, { const textOnlyFiles = Object.fromEntries(Object.entries(exported.files).filter(([, v]) => typeof v === "string"));
expect(companySkillSvc.importPackageFiles).toHaveBeenCalledWith("company-imported", textOnlyFiles, {
onConflict: "rename", onConflict: "rename",
}); });
}); });

View file

@ -98,6 +98,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = {
agents: true, agents: true,
projects: false, projects: false,
issues: false, issues: false,
skills: false,
}; };
const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename";
@ -561,6 +562,7 @@ function normalizeInclude(input?: Partial<CompanyPortabilityInclude>): CompanyPo
agents: input?.agents ?? DEFAULT_INCLUDE.agents, agents: input?.agents ?? DEFAULT_INCLUDE.agents,
projects: input?.projects ?? DEFAULT_INCLUDE.projects, projects: input?.projects ?? DEFAULT_INCLUDE.projects,
issues: input?.issues ?? DEFAULT_INCLUDE.issues, issues: input?.issues ?? DEFAULT_INCLUDE.issues,
skills: input?.skills ?? DEFAULT_INCLUDE.skills,
}; };
} }
@ -1193,6 +1195,7 @@ function applySelectedFilesToSource(source: ResolvedSource, selectedFiles?: stri
agents: filtered.manifest.agents.length > 0, agents: filtered.manifest.agents.length > 0,
projects: filtered.manifest.projects.length > 0, projects: filtered.manifest.projects.length > 0,
issues: filtered.manifest.issues.length > 0, issues: filtered.manifest.issues.length > 0,
skills: filtered.manifest.skills.length > 0,
}; };
return filtered; return filtered;
@ -1656,6 +1659,7 @@ function buildManifestFromPackageFiles(
agents: true, agents: true,
projects: projectPaths.length > 0, projects: projectPaths.length > 0,
issues: taskPaths.length > 0, issues: taskPaths.length > 0,
skills: skillPaths.length > 0,
}, },
company: { company: {
path: resolvedCompanyPath, path: resolvedCompanyPath,
@ -2051,6 +2055,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
(input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0) (input.issues && input.issues.length > 0) || (input.projectIssues && input.projectIssues.length > 0)
? true ? true
: input.include?.issues, : input.include?.issues,
skills: input.skills && input.skills.length > 0 ? true : input.include?.skills,
}); });
const company = await companies.getById(companyId); const company = await companies.getById(companyId);
if (!company) throw notFound("Company not found"); if (!company) throw notFound("Company not found");
@ -2063,7 +2068,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : []; const allAgentRows = include.agents ? await agents.list(companyId, { includeTerminated: true }) : [];
const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated"); const liveAgentRows = allAgentRows.filter((agent) => agent.status !== "terminated");
const companySkillRows = await companySkills.listFull(companyId); const companySkillRows = include.skills || include.agents ? await companySkills.listFull(companyId) : [];
if (include.agents) { if (include.agents) {
const skipped = allAgentRows.length - liveAgentRows.length; const skipped = allAgentRows.length - liveAgentRows.length;
if (skipped > 0) { if (skipped > 0) {
@ -2464,6 +2469,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
agents: resolved.manifest.agents.length > 0, agents: resolved.manifest.agents.length > 0,
projects: resolved.manifest.projects.length > 0, projects: resolved.manifest.projects.length > 0,
issues: resolved.manifest.issues.length > 0, issues: resolved.manifest.issues.length > 0,
skills: resolved.manifest.skills.length > 0,
}; };
resolved.manifest.envInputs = dedupeEnvInputs(envInputs); resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
resolved.warnings.unshift(...warnings); resolved.warnings.unshift(...warnings);
@ -2497,6 +2503,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
agents: resolved.manifest.agents.length > 0, agents: resolved.manifest.agents.length > 0,
projects: resolved.manifest.projects.length > 0, projects: resolved.manifest.projects.length > 0,
issues: resolved.manifest.issues.length > 0, issues: resolved.manifest.issues.length > 0,
skills: resolved.manifest.skills.length > 0,
}; };
resolved.manifest.envInputs = dedupeEnvInputs(envInputs); resolved.manifest.envInputs = dedupeEnvInputs(envInputs);
resolved.warnings.unshift(...warnings); resolved.warnings.unshift(...warnings);
@ -2559,6 +2566,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
agents: requestedInclude.agents && manifest.agents.length > 0, agents: requestedInclude.agents && manifest.agents.length > 0,
projects: requestedInclude.projects && manifest.projects.length > 0, projects: requestedInclude.projects && manifest.projects.length > 0,
issues: requestedInclude.issues && manifest.issues.length > 0, issues: requestedInclude.issues && manifest.issues.length > 0,
skills: requestedInclude.skills && manifest.skills.length > 0,
}; };
const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY; const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY;
if (mode === "agent_safe" && collisionStrategy === "replace") { if (mode === "agent_safe" && collisionStrategy === "replace") {
@ -3019,9 +3027,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
existingProjectSlugToId.set(existing.urlKey, existing.id); existingProjectSlugToId.set(existing.urlKey, existing.id);
} }
const importedSkills = await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), { const importedSkills = include.skills || include.agents
onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy), ? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), {
}); onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy),
})
: [];
const desiredSkillRefMap = new Map<string, string>(); const desiredSkillRefMap = new Map<string, string>();
for (const importedSkill of importedSkills) { for (const importedSkill of importedSkills) {
desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key); desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);