diff --git a/cli/src/__tests__/company-import-url.test.ts b/cli/src/__tests__/company-import-url.test.ts new file mode 100644 index 00000000..a749d57e --- /dev/null +++ b/cli/src/__tests__/company-import-url.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { isHttpUrl, isGithubUrl } from "../commands/client/company.js"; + +describe("isHttpUrl", () => { + it("matches http URLs", () => { + expect(isHttpUrl("http://example.com/foo")).toBe(true); + }); + + it("matches https URLs", () => { + expect(isHttpUrl("https://example.com/foo")).toBe(true); + }); + + it("rejects local paths", () => { + expect(isHttpUrl("/tmp/my-company")).toBe(false); + expect(isHttpUrl("./relative")).toBe(false); + }); +}); + +describe("isGithubUrl", () => { + it("matches GitHub URLs", () => { + expect(isGithubUrl("https://github.com/org/repo")).toBe(true); + }); + + it("rejects non-GitHub HTTP URLs", () => { + expect(isGithubUrl("https://example.com/foo")).toBe(false); + }); + + it("rejects local paths", () => { + expect(isGithubUrl("/tmp/my-company")).toBe(false); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 05cba06e..9e563387 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -34,6 +34,7 @@ interface CompanyDeleteOptions extends BaseClientOptions { interface CompanyExportOptions extends BaseClientOptions { out?: string; include?: string; + skills?: string; projects?: string; issues?: string; projectIssues?: string; @@ -112,11 +113,11 @@ function parseCsvValues(input: string | undefined): string[] { return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean))); } -function isHttpUrl(input: string): boolean { +export function isHttpUrl(input: string): boolean { return /^https?:\/\//i.test(input.trim()); } -function isGithubUrl(input: string): boolean { +export function isGithubUrl(input: string): boolean { return /^https?:\/\/github\.com\//i.test(input.trim()); } @@ -337,6 +338,7 @@ export function registerCompanyCommands(program: Command): void { .argument("", "Company ID") .requiredOption("--out ", "Output directory") .option("--include ", "Comma-separated include set: company,agents,projects,issues", "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") .option("--project-issues ", "Comma-separated project shortnames/ids whose issues should be exported") @@ -349,6 +351,7 @@ export function registerCompanyCommands(program: Command): void { `/api/companies/${companyId}/export`, { include, + skills: parseCsvValues(opts.skills), projects: parseCsvValues(opts.projects), issues: parseCsvValues(opts.issues), projectIssues: parseCsvValues(opts.projectIssues), @@ -433,13 +436,16 @@ export function registerCompanyCommands(program: Command): void { let sourcePayload: | { type: "inline"; rootPath?: string | null; files: Record } - | { type: "url"; url: string } | { type: "github"; url: string }; if (isHttpUrl(from)) { - sourcePayload = isGithubUrl(from) - ? { type: "github", url: from } - : { type: "url", url: from }; + if (!isGithubUrl(from)) { + throw new Error( + "Only GitHub URLs and local paths are supported for import. " + + "Generic HTTP URLs are not supported. Use a GitHub URL (https://github.com/...) or a local directory path.", + ); + } + sourcePayload = { type: "github", url: from }; } else { const inline = await resolveInlineSourceFromPath(from); sourcePayload = { diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 5ee3aeca..a24775b4 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -356,6 +356,42 @@ describe("company portability", () => { expect(asTextFile(exported.files["skills/paperclipai/paperclip/paperclip/references/api.md"])).toContain("# API"); }); + it("exports only selected skills when skills filter is provided", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + skills: ["company-playbook"], + }); + + expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toBeDefined(); + expect(asTextFile(exported.files["skills/company/PAP/company-playbook/SKILL.md"])).toContain("# Company Playbook"); + expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeUndefined(); + }); + + it("warns and exports all skills when skills filter matches nothing", async () => { + const portability = companyPortabilityService({} as any); + + const exported = await portability.exportBundle("company-1", { + include: { + company: true, + agents: true, + projects: false, + issues: false, + }, + skills: ["nonexistent-skill"], + }); + + expect(exported.warnings).toContainEqual(expect.stringContaining("nonexistent-skill")); + expect(exported.files["skills/company/PAP/company-playbook/SKILL.md"]).toBeDefined(); + expect(exported.files["skills/paperclipai/paperclip/paperclip/SKILL.md"]).toBeDefined(); + }); + it("exports the company logo into images/ and references it from .paperclip.yaml", async () => { const storage = { getObject: vi.fn().mockResolvedValue({