From 5f2b1b63c2cabc584b1e690248abca24572ca8f2 Mon Sep 17 00:00:00 2001 From: dotta Date: Fri, 20 Mar 2026 06:20:30 -0500 Subject: [PATCH] Add explicit skill selection to company portability --- cli/src/commands/client/company.ts | 13 +++++++------ .../shared/src/types/company-portability.ts | 1 + .../src/validators/company-portability.ts | 2 ++ .../company-portability-routes.test.ts | 2 +- .../src/__tests__/company-portability.test.ts | 6 ++++-- server/src/services/company-portability.ts | 18 ++++++++++++++---- 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 9e563387..01de4548 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -85,16 +85,17 @@ function normalizeSelector(input: string): string { } 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 include = { company: values.includes("company"), agents: values.includes("agents"), 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) { - throw new Error("Invalid --include value. Use one or more of: company,agents,projects,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,tasks,skills"); } return include; } @@ -337,7 +338,7 @@ export function registerCompanyCommands(program: Command): void { .description("Export a company into a portable markdown package") .argument("", "Company ID") .requiredOption("--out ", "Output directory") - .option("--include ", "Comma-separated include set: company,agents,projects,issues", "company,agents") + .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") .option("--skills ", "Comma-separated skill slugs/keys to export") .option("--projects ", "Comma-separated project shortnames/ids to export") .option("--issues ", "Comma-separated issue identifiers/ids to export") @@ -390,7 +391,7 @@ export function registerCompanyCommands(program: Command): void { .command("import") .description("Import a portable markdown company package from local path, URL, or GitHub") .requiredOption("--from ", "Source path or URL") - .option("--include ", "Comma-separated include set: company,agents,projects,issues", "company,agents") + .option("--include ", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents") .option("--target ", "Target mode: new | existing") .option("-C, --company-id ", "Existing target company ID") .option("--new-company-name ", "Name override for --target new") diff --git a/packages/shared/src/types/company-portability.ts b/packages/shared/src/types/company-portability.ts index 811c88a6..26088831 100644 --- a/packages/shared/src/types/company-portability.ts +++ b/packages/shared/src/types/company-portability.ts @@ -3,6 +3,7 @@ export interface CompanyPortabilityInclude { agents: boolean; projects: boolean; issues: boolean; + skills: boolean; } export interface CompanyPortabilityEnvInput { diff --git a/packages/shared/src/validators/company-portability.ts b/packages/shared/src/validators/company-portability.ts index c45eed6a..cae50e89 100644 --- a/packages/shared/src/validators/company-portability.ts +++ b/packages/shared/src/validators/company-portability.ts @@ -6,6 +6,7 @@ export const portabilityIncludeSchema = z agents: z.boolean().optional(), projects: z.boolean().optional(), issues: z.boolean().optional(), + skills: z.boolean().optional(), }) .partial(); @@ -119,6 +120,7 @@ export const portabilityManifestSchema = z.object({ agents: z.boolean(), projects: z.boolean(), issues: z.boolean(), + skills: z.boolean(), }), company: portabilityCompanyManifestEntrySchema.nullable(), agents: z.array(portabilityAgentManifestEntrySchema), diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index bf1c16d3..9fabef46 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -97,7 +97,7 @@ describe("company portability routes", () => { }); mockCompanyPortabilityService.previewExport.mockResolvedValue({ 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: {}, fileInventory: [], counts: { files: 0, agents: 0, skills: 0, projects: 0, issues: 0 }, diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index a24775b4..9a79f392 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -666,7 +666,8 @@ describe("company portability", () => { collisionStrategy: "rename", }, "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", }); expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ @@ -812,7 +813,8 @@ describe("company portability", () => { expect(accessSvc.listActiveUserMemberships).toHaveBeenCalledWith("company-1"); expect(accessSvc.copyActiveUserMemberships).toHaveBeenCalledWith("company-1", "company-imported"); 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", }); }); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 3772fb25..6ae4c431 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -98,6 +98,7 @@ const DEFAULT_INCLUDE: CompanyPortabilityInclude = { agents: true, projects: false, issues: false, + skills: false, }; const DEFAULT_COLLISION_STRATEGY: CompanyPortabilityCollisionStrategy = "rename"; @@ -561,6 +562,7 @@ function normalizeInclude(input?: Partial): CompanyPo agents: input?.agents ?? DEFAULT_INCLUDE.agents, projects: input?.projects ?? DEFAULT_INCLUDE.projects, 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, projects: filtered.manifest.projects.length > 0, issues: filtered.manifest.issues.length > 0, + skills: filtered.manifest.skills.length > 0, }; return filtered; @@ -1656,6 +1659,7 @@ function buildManifestFromPackageFiles( agents: true, projects: projectPaths.length > 0, issues: taskPaths.length > 0, + skills: skillPaths.length > 0, }, company: { 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) ? true : input.include?.issues, + skills: input.skills && input.skills.length > 0 ? true : input.include?.skills, }); const company = await companies.getById(companyId); 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 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) { const skipped = allAgentRows.length - liveAgentRows.length; if (skipped > 0) { @@ -2464,6 +2469,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { agents: resolved.manifest.agents.length > 0, projects: resolved.manifest.projects.length > 0, issues: resolved.manifest.issues.length > 0, + skills: resolved.manifest.skills.length > 0, }; resolved.manifest.envInputs = dedupeEnvInputs(envInputs); resolved.warnings.unshift(...warnings); @@ -2497,6 +2503,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { agents: resolved.manifest.agents.length > 0, projects: resolved.manifest.projects.length > 0, issues: resolved.manifest.issues.length > 0, + skills: resolved.manifest.skills.length > 0, }; resolved.manifest.envInputs = dedupeEnvInputs(envInputs); resolved.warnings.unshift(...warnings); @@ -2559,6 +2566,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { agents: requestedInclude.agents && manifest.agents.length > 0, projects: requestedInclude.projects && manifest.projects.length > 0, issues: requestedInclude.issues && manifest.issues.length > 0, + skills: requestedInclude.skills && manifest.skills.length > 0, }; const collisionStrategy = input.collisionStrategy ?? DEFAULT_COLLISION_STRATEGY; if (mode === "agent_safe" && collisionStrategy === "replace") { @@ -3019,9 +3027,11 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { existingProjectSlugToId.set(existing.urlKey, existing.id); } - const importedSkills = await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), { - onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy), - }); + const importedSkills = include.skills || include.agents + ? await companySkills.importPackageFiles(targetCompany.id, pickTextFiles(plan.source.files), { + onConflict: resolveSkillConflictStrategy(mode, plan.collisionStrategy), + }) + : []; const desiredSkillRefMap = new Map(); for (const importedSkill of importedSkills) { desiredSkillRefMap.set(importedSkill.originalKey, importedSkill.skill.key);