From e6df9fa078d50a7a99bbbf5876ab0aec35dcb411 Mon Sep 17 00:00:00 2001 From: dotta Date: Mon, 23 Mar 2026 06:47:32 -0500 Subject: [PATCH] Support GitHub shorthand refs for company import Co-Authored-By: Paperclip --- cli/src/__tests__/company-import-url.test.ts | 45 ++++++- cli/src/commands/client/company.ts | 121 +++++++++++++++++- doc/AGENTCOMPANIES_SPEC_INVENTORY.md | 2 +- docs/cli/control-plane-commands.md | 3 +- .../src/__tests__/company-portability.test.ts | 28 +++- server/src/services/company-portability.ts | 25 +++- 6 files changed, 215 insertions(+), 9 deletions(-) diff --git a/cli/src/__tests__/company-import-url.test.ts b/cli/src/__tests__/company-import-url.test.ts index a749d57e..abc96f7d 100644 --- a/cli/src/__tests__/company-import-url.test.ts +++ b/cli/src/__tests__/company-import-url.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { isHttpUrl, isGithubUrl } from "../commands/client/company.js"; +import { + isGithubShorthand, + isGithubUrl, + isHttpUrl, + normalizeGithubImportSource, +} from "../commands/client/company.js"; describe("isHttpUrl", () => { it("matches http URLs", () => { @@ -29,3 +34,41 @@ describe("isGithubUrl", () => { expect(isGithubUrl("/tmp/my-company")).toBe(false); }); }); + +describe("isGithubShorthand", () => { + it("matches owner/repo/path shorthands", () => { + expect(isGithubShorthand("paperclipai/companies/gstack")).toBe(true); + expect(isGithubShorthand("paperclipai/companies")).toBe(true); + }); + + it("rejects local-looking paths", () => { + expect(isGithubShorthand("./exports/acme")).toBe(false); + expect(isGithubShorthand("/tmp/acme")).toBe(false); + expect(isGithubShorthand("C:\\temp\\acme")).toBe(false); + }); +}); + +describe("normalizeGithubImportSource", () => { + it("normalizes shorthand imports to canonical GitHub sources", () => { + expect(normalizeGithubImportSource("paperclipai/companies/gstack")).toBe( + "https://github.com/paperclipai/companies?ref=main&path=gstack", + ); + }); + + it("applies --ref to shorthand imports", () => { + expect(normalizeGithubImportSource("paperclipai/companies/gstack", "feature/demo")).toBe( + "https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack", + ); + }); + + it("applies --ref to existing GitHub tree URLs without losing the package path", () => { + expect( + normalizeGithubImportSource( + "https://github.com/paperclipai/companies/tree/main/gstack", + "release/2026-03-23", + ), + ).toBe( + "https://github.com/paperclipai/companies?ref=release%2F2026-03-23&path=gstack", + ); + }); +}); diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index 96bfa835..17824dbd 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -42,13 +42,13 @@ interface CompanyExportOptions extends BaseClientOptions { } interface CompanyImportOptions extends BaseClientOptions { - from?: string; include?: string; target?: CompanyImportTargetMode; companyId?: string; newCompanyName?: string; agents?: string; collision?: CompanyCollisionMode; + ref?: string; dryRun?: boolean; } @@ -122,6 +122,112 @@ export function isGithubUrl(input: string): boolean { return /^https?:\/\/github\.com\//i.test(input.trim()); } +function isGithubSegment(input: string): boolean { + return /^[A-Za-z0-9._-]+$/.test(input); +} + +export function isGithubShorthand(input: string): boolean { + const trimmed = input.trim(); + if (!trimmed || isHttpUrl(trimmed)) return false; + if ( + trimmed.startsWith(".") || + trimmed.startsWith("/") || + trimmed.startsWith("~") || + trimmed.includes("\\") || + /^[A-Za-z]:/.test(trimmed) + ) { + return false; + } + + const segments = trimmed.split("/").filter(Boolean); + return segments.length >= 2 && segments.every(isGithubSegment); +} + +function normalizeGithubImportPath(input: string | null | undefined): string | null { + if (!input) return null; + const trimmed = input.trim().replace(/^\/+|\/+$/g, ""); + return trimmed || null; +} + +function buildGithubImportUrl(input: { + owner: string; + repo: string; + ref?: string | null; + path?: string | null; + companyPath?: string | null; +}): string { + const url = new URL(`https://github.com/${input.owner}/${input.repo.replace(/\.git$/i, "")}`); + const ref = input.ref?.trim(); + if (ref) { + url.searchParams.set("ref", ref); + } + const companyPath = normalizeGithubImportPath(input.companyPath); + if (companyPath) { + url.searchParams.set("companyPath", companyPath); + return url.toString(); + } + const sourcePath = normalizeGithubImportPath(input.path); + if (sourcePath) { + url.searchParams.set("path", sourcePath); + } + return url.toString(); +} + +export function normalizeGithubImportSource(input: string, refOverride?: string): string { + const trimmed = input.trim(); + const ref = refOverride?.trim(); + + if (isGithubShorthand(trimmed)) { + const [owner, repo, ...repoPath] = trimmed.split("/").filter(Boolean); + return buildGithubImportUrl({ + owner: owner!, + repo: repo!, + ref: ref || "main", + path: repoPath.join("/"), + }); + } + + if (!isGithubUrl(trimmed)) { + throw new Error("GitHub source must be a github.com URL or owner/repo[/path] shorthand."); + } + if (!ref) { + return trimmed; + } + + const url = new URL(trimmed); + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + throw new Error("Invalid GitHub URL."); + } + + const owner = parts[0]!; + const repo = parts[1]!; + const existingPath = normalizeGithubImportPath(url.searchParams.get("path")); + const existingCompanyPath = normalizeGithubImportPath(url.searchParams.get("companyPath")); + if (existingCompanyPath) { + return buildGithubImportUrl({ owner, repo, ref, companyPath: existingCompanyPath }); + } + if (existingPath) { + return buildGithubImportUrl({ owner, repo, ref, path: existingPath }); + } + if (parts[2] === "tree") { + return buildGithubImportUrl({ owner, repo, ref, path: parts.slice(4).join("/") }); + } + if (parts[2] === "blob") { + return buildGithubImportUrl({ owner, repo, ref, companyPath: parts.slice(4).join("/") }); + } + return buildGithubImportUrl({ owner, repo, ref }); +} + +async function pathExists(inputPath: string): Promise { + try { + await stat(path.resolve(inputPath)); + return true; + } catch { + return false; + } +} + async function collectPackageFiles( root: string, current: string, @@ -397,6 +503,7 @@ export function registerCompanyCommands(program: Command): void { .option("--new-company-name ", "Name override for --target new") .option("--agents ", "Comma-separated agent slugs to import, or all", "all") .option("--collision ", "Collision strategy: rename | skip | replace", "rename") + .option("--ref ", "Git ref to use for GitHub imports (branch, tag, or commit)") .option("--dry-run", "Run preview only without applying", false) .action(async (fromPathOrUrl: string, opts: CompanyImportOptions) => { try { @@ -439,15 +546,21 @@ export function registerCompanyCommands(program: Command): void { | { type: "inline"; rootPath?: string | null; files: Record } | { type: "github"; url: string }; - if (isHttpUrl(from)) { - if (!isGithubUrl(from)) { + const treatAsLocalPath = !isHttpUrl(from) && await pathExists(from); + const isGithubSource = isGithubUrl(from) || (isGithubShorthand(from) && !treatAsLocalPath); + + if (isHttpUrl(from) || isGithubSource) { + if (!isGithubUrl(from) && !isGithubShorthand(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 }; + sourcePayload = { type: "github", url: normalizeGithubImportSource(from, opts.ref) }; } else { + if (opts.ref?.trim()) { + throw new Error("--ref is only supported for GitHub import sources."); + } const inline = await resolveInlineSourceFromPath(from); sourcePayload = { type: "inline", diff --git a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md index 91799bea..a3376a89 100644 --- a/doc/AGENTCOMPANIES_SPEC_INVENTORY.md +++ b/doc/AGENTCOMPANIES_SPEC_INVENTORY.md @@ -60,7 +60,7 @@ Route registration lives in `server/src/app.ts` via `companyRoutes(db, storage)` | File | Commands | |---|---| -| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import ` — imports a company package from a file or folder (flags: positional source path/URL, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--dryRun`).
Reads/writes portable file entries and handles `.paperclip.yaml` filtering. | +| `cli/src/commands/client/company.ts` | `company export` — exports a company package to disk (flags: `--out`, `--include`, `--projects`, `--issues`, `--projectIssues`).
`company import ` — imports a company package from a file or folder (flags: positional source path/URL or GitHub shorthand, `--include`, `--target`, `--companyId`, `--newCompanyName`, `--agents`, `--collision`, `--ref`, `--dryRun`).
Reads/writes portable file entries and handles `.paperclip.yaml` filtering. | ## 7. UI — Pages diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index f6eb33d7..80eb0edb 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -41,9 +41,10 @@ pnpm paperclipai company export --out ./exports/acme --include comp # Preview import (no writes) pnpm paperclipai company import \ - https://github.com///tree/main/ \ + // \ --target existing \ --company-id \ + --ref main \ --collision rename \ --dry-run diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index ff019530..0e593369 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -87,7 +87,7 @@ vi.mock("../routes/org-chart-svg.js", () => ({ renderOrgChartPng: vi.fn(async () => Buffer.from("png")), })); -const { companyPortabilityService } = await import("../services/company-portability.js"); +const { companyPortabilityService, parseGitHubSourceUrl } = await import("../services/company-portability.js"); function asTextFile(entry: CompanyPortabilityFileEntry | undefined) { expect(typeof entry).toBe("string"); @@ -301,6 +301,32 @@ describe("company portability", () => { })); }); + it("parses canonical GitHub import URLs with explicit ref and package path", () => { + expect( + parseGitHubSourceUrl("https://github.com/paperclipai/companies?ref=feature%2Fdemo&path=gstack"), + ).toEqual({ + owner: "paperclipai", + repo: "companies", + ref: "feature/demo", + basePath: "gstack", + companyPath: "gstack/COMPANY.md", + }); + }); + + it("parses canonical GitHub import URLs with explicit companyPath", () => { + expect( + parseGitHubSourceUrl( + "https://github.com/paperclipai/companies?ref=abc123&companyPath=gstack%2FCOMPANY.md", + ), + ).toEqual({ + owner: "paperclipai", + repo: "companies", + ref: "abc123", + basePath: "gstack", + companyPath: "gstack/COMPANY.md", + }); + }); + it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index d063fd36..704085dd 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -1898,7 +1898,12 @@ function buildManifestFromPackageFiles( } -function parseGitHubSourceUrl(rawUrl: string) { +function normalizeGitHubSourcePath(value: string | null | undefined) { + if (!value) return ""; + return value.trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, ""); +} + +export function parseGitHubSourceUrl(rawUrl: string) { const url = new URL(rawUrl); if (url.hostname !== "github.com") { throw unprocessable("GitHub source must use github.com URL"); @@ -1909,6 +1914,24 @@ function parseGitHubSourceUrl(rawUrl: string) { } const owner = parts[0]!; const repo = parts[1]!.replace(/\.git$/i, ""); + const queryRef = url.searchParams.get("ref")?.trim(); + const queryPath = normalizeGitHubSourcePath(url.searchParams.get("path")); + const queryCompanyPath = normalizeGitHubSourcePath(url.searchParams.get("companyPath")); + if (queryRef || queryPath || queryCompanyPath) { + const companyPath = queryCompanyPath || [queryPath, "COMPANY.md"].filter(Boolean).join("/") || "COMPANY.md"; + let basePath = queryPath; + if (!basePath && companyPath !== "COMPANY.md") { + basePath = path.posix.dirname(companyPath); + if (basePath === ".") basePath = ""; + } + return { + owner, + repo, + ref: queryRef || "main", + basePath, + companyPath, + }; + } let ref = "main"; let basePath = ""; let companyPath = "COMPANY.md";