Files
paperclip/cli/src/commands/client/company.ts

569 lines
20 KiB
TypeScript

import { Command } from "commander";
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import * as p from "@clack/prompts";
import type {
Company,
CompanyPortabilityFileEntry,
CompanyPortabilityExportResult,
CompanyPortabilityInclude,
CompanyPortabilityPreviewResult,
CompanyPortabilityImportResult,
} from "@paperclipai/shared";
import { ApiRequestError } from "../../client/http.js";
import {
addCommonClientOptions,
formatInlineRecord,
handleCommandError,
printOutput,
resolveCommandContext,
type BaseClientOptions,
} from "./common.js";
interface CompanyCommandOptions extends BaseClientOptions {}
type CompanyDeleteSelectorMode = "auto" | "id" | "prefix";
type CompanyImportTargetMode = "new" | "existing";
type CompanyCollisionMode = "rename" | "skip" | "replace";
interface CompanyDeleteOptions extends BaseClientOptions {
by?: CompanyDeleteSelectorMode;
yes?: boolean;
confirm?: string;
}
interface CompanyExportOptions extends BaseClientOptions {
out?: string;
include?: string;
skills?: string;
projects?: string;
issues?: string;
projectIssues?: string;
expandReferencedSkills?: boolean;
}
interface CompanyImportOptions extends BaseClientOptions {
from?: string;
include?: string;
target?: CompanyImportTargetMode;
companyId?: string;
newCompanyName?: string;
agents?: string;
collision?: CompanyCollisionMode;
dryRun?: boolean;
}
const binaryContentTypeByExtension: Record<string, string> = {
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
};
function readPortableFileEntry(filePath: string, contents: Buffer): CompanyPortabilityFileEntry {
const contentType = binaryContentTypeByExtension[path.extname(filePath).toLowerCase()];
if (!contentType) return contents.toString("utf8");
return {
encoding: "base64",
data: contents.toString("base64"),
contentType,
};
}
function portableFileEntryToWriteValue(entry: CompanyPortabilityFileEntry): string | Uint8Array {
if (typeof entry === "string") return entry;
return Buffer.from(entry.data, "base64");
}
function isUuidLike(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function normalizeSelector(input: string): string {
return input.trim();
}
function parseInclude(input: string | undefined): CompanyPortabilityInclude {
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") || values.includes("tasks"),
skills: values.includes("skills"),
};
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;
}
function parseAgents(input: string | undefined): "all" | string[] {
if (!input || !input.trim()) return "all";
const normalized = input.trim().toLowerCase();
if (normalized === "all") return "all";
const values = input.split(",").map((part) => part.trim()).filter(Boolean);
if (values.length === 0) return "all";
return Array.from(new Set(values));
}
function parseCsvValues(input: string | undefined): string[] {
if (!input || !input.trim()) return [];
return Array.from(new Set(input.split(",").map((part) => part.trim()).filter(Boolean)));
}
export function isHttpUrl(input: string): boolean {
return /^https?:\/\//i.test(input.trim());
}
export function isGithubUrl(input: string): boolean {
return /^https?:\/\/github\.com\//i.test(input.trim());
}
async function collectPackageFiles(
root: string,
current: string,
files: Record<string, CompanyPortabilityFileEntry>,
): Promise<void> {
const entries = await readdir(current, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".git")) continue;
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
await collectPackageFiles(root, absolutePath, files);
continue;
}
if (!entry.isFile()) continue;
const isMarkdown = entry.name.endsWith(".md");
const isPaperclipYaml = entry.name === ".paperclip.yaml" || entry.name === ".paperclip.yml";
const contentType = binaryContentTypeByExtension[path.extname(entry.name).toLowerCase()];
if (!isMarkdown && !isPaperclipYaml && !contentType) continue;
const relativePath = path.relative(root, absolutePath).replace(/\\/g, "/");
files[relativePath] = readPortableFileEntry(relativePath, await readFile(absolutePath));
}
}
async function resolveInlineSourceFromPath(inputPath: string): Promise<{
rootPath: string;
files: Record<string, CompanyPortabilityFileEntry>;
}> {
const resolved = path.resolve(inputPath);
const resolvedStat = await stat(resolved);
const rootDir = resolvedStat.isDirectory() ? resolved : path.dirname(resolved);
const files: Record<string, CompanyPortabilityFileEntry> = {};
await collectPackageFiles(rootDir, rootDir, files);
return {
rootPath: path.basename(rootDir),
files,
};
}
async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise<void> {
const root = path.resolve(outDir);
await mkdir(root, { recursive: true });
for (const [relativePath, content] of Object.entries(exported.files)) {
const normalized = relativePath.replace(/\\/g, "/");
const filePath = path.join(root, normalized);
await mkdir(path.dirname(filePath), { recursive: true });
const writeValue = portableFileEntryToWriteValue(content);
if (typeof writeValue === "string") {
await writeFile(filePath, writeValue, "utf8");
} else {
await writeFile(filePath, writeValue);
}
}
}
async function confirmOverwriteExportDirectory(outDir: string): Promise<void> {
const root = path.resolve(outDir);
const stats = await stat(root).catch(() => null);
if (!stats) return;
if (!stats.isDirectory()) {
throw new Error(`Export output path ${root} exists and is not a directory.`);
}
const entries = await readdir(root);
if (entries.length === 0) return;
if (!process.stdin.isTTY || !process.stdout.isTTY) {
throw new Error(`Export output directory ${root} already contains files. Re-run interactively or choose an empty directory.`);
}
const confirmed = await p.confirm({
message: `Overwrite existing files in ${root}?`,
initialValue: false,
});
if (p.isCancel(confirmed) || !confirmed) {
throw new Error("Export cancelled.");
}
}
function matchesPrefix(company: Company, selector: string): boolean {
return company.issuePrefix.toUpperCase() === selector.toUpperCase();
}
export function resolveCompanyForDeletion(
companies: Company[],
selectorRaw: string,
by: CompanyDeleteSelectorMode = "auto",
): Company {
const selector = normalizeSelector(selectorRaw);
if (!selector) {
throw new Error("Company selector is required.");
}
const idMatch = companies.find((company) => company.id === selector);
const prefixMatch = companies.find((company) => matchesPrefix(company, selector));
if (by === "id") {
if (!idMatch) {
throw new Error(`No company found by ID '${selector}'.`);
}
return idMatch;
}
if (by === "prefix") {
if (!prefixMatch) {
throw new Error(`No company found by shortname/prefix '${selector}'.`);
}
return prefixMatch;
}
if (idMatch && prefixMatch && idMatch.id !== prefixMatch.id) {
throw new Error(
`Selector '${selector}' is ambiguous (matches both an ID and a shortname). Re-run with --by id or --by prefix.`,
);
}
if (idMatch) return idMatch;
if (prefixMatch) return prefixMatch;
throw new Error(
`No company found for selector '${selector}'. Use company ID or issue prefix (for example PAP).`,
);
}
export function assertDeleteConfirmation(company: Company, opts: CompanyDeleteOptions): void {
if (!opts.yes) {
throw new Error("Deletion requires --yes.");
}
const confirm = opts.confirm?.trim();
if (!confirm) {
throw new Error(
"Deletion requires --confirm <value> where value matches the company ID or issue prefix.",
);
}
const confirmsById = confirm === company.id;
const confirmsByPrefix = confirm.toUpperCase() === company.issuePrefix.toUpperCase();
if (!confirmsById && !confirmsByPrefix) {
throw new Error(
`Confirmation '${confirm}' does not match target company. Expected ID '${company.id}' or prefix '${company.issuePrefix}'.`,
);
}
}
function assertDeleteFlags(opts: CompanyDeleteOptions): void {
if (!opts.yes) {
throw new Error("Deletion requires --yes.");
}
if (!opts.confirm?.trim()) {
throw new Error(
"Deletion requires --confirm <value> where value matches the company ID or issue prefix.",
);
}
}
export function registerCompanyCommands(program: Command): void {
const company = program.command("company").description("Company operations");
addCommonClientOptions(
company
.command("list")
.description("List companies")
.action(async (opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
const rows = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
if (ctx.json) {
printOutput(rows, { json: true });
return;
}
if (rows.length === 0) {
printOutput([], { json: false });
return;
}
const formatted = rows.map((row) => ({
id: row.id,
name: row.name,
status: row.status,
budgetMonthlyCents: row.budgetMonthlyCents,
spentMonthlyCents: row.spentMonthlyCents,
requireBoardApprovalForNewAgents: row.requireBoardApprovalForNewAgents,
}));
for (const row of formatted) {
console.log(formatInlineRecord(row));
}
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
company
.command("get")
.description("Get one company")
.argument("<companyId>", "Company ID")
.action(async (companyId: string, opts: CompanyCommandOptions) => {
try {
const ctx = resolveCommandContext(opts);
const row = await ctx.api.get<Company>(`/api/companies/${companyId}`);
printOutput(row, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
company
.command("export")
.description("Export a company into a portable markdown package")
.argument("<companyId>", "Company ID")
.requiredOption("--out <path>", "Output directory")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "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")
.option("--expand-referenced-skills", "Vendor skill contents instead of exporting upstream references", false)
.action(async (companyId: string, opts: CompanyExportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const include = parseInclude(opts.include);
const exported = await ctx.api.post<CompanyPortabilityExportResult>(
`/api/companies/${companyId}/export`,
{
include,
skills: parseCsvValues(opts.skills),
projects: parseCsvValues(opts.projects),
issues: parseCsvValues(opts.issues),
projectIssues: parseCsvValues(opts.projectIssues),
expandReferencedSkills: Boolean(opts.expandReferencedSkills),
},
);
if (!exported) {
throw new Error("Export request returned no data");
}
await confirmOverwriteExportDirectory(opts.out!);
await writeExportToFolder(opts.out!, exported);
printOutput(
{
ok: true,
out: path.resolve(opts.out!),
rootPath: exported.rootPath,
filesWritten: Object.keys(exported.files).length,
paperclipExtensionPath: exported.paperclipExtensionPath,
warningCount: exported.warnings.length,
},
{ json: ctx.json },
);
if (!ctx.json && exported.warnings.length > 0) {
for (const warning of exported.warnings) {
console.log(`warning=${warning}`);
}
}
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
company
.command("import")
.description("Import a portable markdown company package from local path, URL, or GitHub")
.requiredOption("--from <pathOrUrl>", "Source path or URL")
.option("--include <values>", "Comma-separated include set: company,agents,projects,issues,tasks,skills", "company,agents")
.option("--target <mode>", "Target mode: new | existing")
.option("-C, --company-id <id>", "Existing target company ID")
.option("--new-company-name <name>", "Name override for --target new")
.option("--agents <list>", "Comma-separated agent slugs to import, or all", "all")
.option("--collision <mode>", "Collision strategy: rename | skip | replace", "rename")
.option("--dry-run", "Run preview only without applying", false)
.action(async (opts: CompanyImportOptions) => {
try {
const ctx = resolveCommandContext(opts);
const from = (opts.from ?? "").trim();
if (!from) {
throw new Error("--from is required");
}
const include = parseInclude(opts.include);
const agents = parseAgents(opts.agents);
const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode;
if (!["rename", "skip", "replace"].includes(collision)) {
throw new Error("Invalid --collision value. Use: rename, skip, replace");
}
const inferredTarget = opts.target ?? (opts.companyId || ctx.companyId ? "existing" : "new");
const target = inferredTarget.toLowerCase() as CompanyImportTargetMode;
if (!["new", "existing"].includes(target)) {
throw new Error("Invalid --target value. Use: new | existing");
}
const existingTargetCompanyId = opts.companyId?.trim() || ctx.companyId;
const targetPayload =
target === "existing"
? {
mode: "existing_company" as const,
companyId: existingTargetCompanyId,
}
: {
mode: "new_company" as const,
newCompanyName: opts.newCompanyName?.trim() || null,
};
if (targetPayload.mode === "existing_company" && !targetPayload.companyId) {
throw new Error("Target existing company requires --company-id (or context default companyId).");
}
let sourcePayload:
| { type: "inline"; rootPath?: string | null; files: Record<string, CompanyPortabilityFileEntry> }
| { type: "github"; url: string };
if (isHttpUrl(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 = {
type: "inline",
rootPath: inline.rootPath,
files: inline.files,
};
}
const payload = {
source: sourcePayload,
include,
target: targetPayload,
agents,
collisionStrategy: collision,
};
if (opts.dryRun) {
const preview = await ctx.api.post<CompanyPortabilityPreviewResult>(
"/api/companies/import/preview",
payload,
);
printOutput(preview, { json: ctx.json });
return;
}
const imported = await ctx.api.post<CompanyPortabilityImportResult>("/api/companies/import", payload);
printOutput(imported, { json: ctx.json });
} catch (err) {
handleCommandError(err);
}
}),
);
addCommonClientOptions(
company
.command("delete")
.description("Delete a company by ID or shortname/prefix (destructive)")
.argument("<selector>", "Company ID or issue prefix (for example PAP)")
.option(
"--by <mode>",
"Selector mode: auto | id | prefix",
"auto",
)
.option("--yes", "Required safety flag to confirm destructive action", false)
.option(
"--confirm <value>",
"Required safety value: target company ID or shortname/prefix",
)
.action(async (selector: string, opts: CompanyDeleteOptions) => {
try {
const by = (opts.by ?? "auto").trim().toLowerCase() as CompanyDeleteSelectorMode;
if (!["auto", "id", "prefix"].includes(by)) {
throw new Error(`Invalid --by mode '${opts.by}'. Expected one of: auto, id, prefix.`);
}
const ctx = resolveCommandContext(opts);
const normalizedSelector = normalizeSelector(selector);
assertDeleteFlags(opts);
let target: Company | null = null;
const shouldTryIdLookup = by === "id" || (by === "auto" && isUuidLike(normalizedSelector));
if (shouldTryIdLookup) {
const byId = await ctx.api.get<Company>(`/api/companies/${normalizedSelector}`, { ignoreNotFound: true });
if (byId) {
target = byId;
} else if (by === "id") {
throw new Error(`No company found by ID '${normalizedSelector}'.`);
}
}
if (!target && ctx.companyId) {
const scoped = await ctx.api.get<Company>(`/api/companies/${ctx.companyId}`, { ignoreNotFound: true });
if (scoped) {
try {
target = resolveCompanyForDeletion([scoped], normalizedSelector, by);
} catch {
// Fallback to board-wide lookup below.
}
}
}
if (!target) {
try {
const companies = (await ctx.api.get<Company[]>("/api/companies")) ?? [];
target = resolveCompanyForDeletion(companies, normalizedSelector, by);
} catch (error) {
if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) {
throw new Error(
"Board access is required to resolve companies across the instance. Use a company ID/prefix for your current company, or run with board authentication.",
);
}
throw error;
}
}
if (!target) {
throw new Error(`No company found for selector '${normalizedSelector}'.`);
}
assertDeleteConfirmation(target, opts);
await ctx.api.delete<{ ok: true }>(`/api/companies/${target.id}`);
printOutput(
{
ok: true,
deletedCompanyId: target.id,
deletedCompanyName: target.name,
deletedCompanyPrefix: target.issuePrefix,
},
{ json: ctx.json },
);
} catch (err) {
handleCommandError(err);
}
}),
);
}