Add --skills flag to company export CLI and fix unsupported URL import path

- Add first-class --skills <list> 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 <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 16:29:11 -05:00
parent ca3fdb3957
commit 531945cfe2
3 changed files with 79 additions and 6 deletions

View File

@@ -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);
});
});

View File

@@ -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("<companyId>", "Company ID")
.requiredOption("--out <path>", "Output directory")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues", "company,agents")
.option("--skills <values>", "Comma-separated skill slugs/keys to export")
.option("--projects <values>", "Comma-separated project shortnames/ids to export")
.option("--issues <values>", "Comma-separated issue identifiers/ids to export")
.option("--project-issues <values>", "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<string, CompanyPortabilityFileEntry> }
| { 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 = {

View File

@@ -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({