From 531945cfe2c5a420c143d442de2ab58e71024685 Mon Sep 17 00:00:00 2001 From: dotta Date: Thu, 19 Mar 2026 16:29:11 -0500 Subject: [PATCH] Add --skills flag to company export CLI and fix unsupported URL import path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add first-class --skills option to `paperclipai company export`, passing through to the existing service support for skill selection - Remove broken `type: "url"` source branch from import command — the shared schema and server only accept `inline | github`, so non-GitHub HTTP URLs now error clearly instead of failing at validation - Export isHttpUrl/isGithubUrl helpers for testability - Add server tests for skills-filtered export (selected + fallback) - Add CLI tests for URL detection helpers Co-Authored-By: Paperclip --- cli/src/__tests__/company-import-url.test.ts | 31 ++++++++++++++++ cli/src/commands/client/company.ts | 18 ++++++---- .../src/__tests__/company-portability.test.ts | 36 +++++++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 cli/src/__tests__/company-import-url.test.ts 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({