2026-02-20 07:10:58 -06:00
import { Command } from "commander" ;
2026-03-13 22:29:30 -05:00
import { mkdir , readdir , readFile , stat , writeFile } from "node:fs/promises" ;
2026-03-02 16:43:59 -06:00
import path from "node:path" ;
2026-03-14 18:59:26 -05:00
import * as p from "@clack/prompts" ;
2026-03-02 16:43:59 -06:00
import type {
Company ,
2026-03-19 07:23:36 -05:00
CompanyPortabilityFileEntry ,
2026-03-02 16:43:59 -06:00
CompanyPortabilityExportResult ,
CompanyPortabilityInclude ,
CompanyPortabilityPreviewResult ,
CompanyPortabilityImportResult ,
2026-03-03 08:45:26 -06:00
} from "@paperclipai/shared" ;
2026-03-02 16:43:59 -06:00
import { ApiRequestError } from "../../client/http.js" ;
2026-02-20 07:10:58 -06:00
import {
addCommonClientOptions ,
formatInlineRecord ,
handleCommandError ,
printOutput ,
resolveCommandContext ,
type BaseClientOptions ,
} from "./common.js" ;
interface CompanyCommandOptions extends BaseClientOptions { }
2026-03-02 16:43:59 -06:00
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 ;
2026-03-19 16:29:11 -05:00
skills? : string ;
2026-03-14 09:46:16 -05:00
projects? : string ;
issues? : string ;
projectIssues? : string ;
2026-03-14 18:59:26 -05:00
expandReferencedSkills? : boolean ;
2026-03-02 16:43:59 -06:00
}
interface CompanyImportOptions extends BaseClientOptions {
from ? : string ;
include? : string ;
target? : CompanyImportTargetMode ;
companyId? : string ;
newCompanyName? : string ;
agents? : string ;
collision? : CompanyCollisionMode ;
dryRun? : boolean ;
}
2026-03-19 07:23:36 -05:00
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" ) ;
}
2026-03-02 16:43:59 -06:00
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 {
2026-03-20 06:20:30 -05:00
if ( ! input || ! input . trim ( ) ) return { company : true , agents : true , projects : false , issues : false , skills : false } ;
2026-03-02 16:43:59 -06:00
const values = input . split ( "," ) . map ( ( part ) = > part . trim ( ) . toLowerCase ( ) ) . filter ( Boolean ) ;
const include = {
company : values.includes ( "company" ) ,
agents : values.includes ( "agents" ) ,
2026-03-14 09:46:16 -05:00
projects : values.includes ( "projects" ) ,
2026-03-20 06:20:30 -05:00
issues : values.includes ( "issues" ) || values . includes ( "tasks" ) ,
skills : values.includes ( "skills" ) ,
2026-03-02 16:43:59 -06:00
} ;
2026-03-20 06:20:30 -05:00
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" ) ;
2026-03-02 16:43:59 -06:00
}
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 ) ) ;
}
2026-03-14 09:46:16 -05:00
function parseCsvValues ( input : string | undefined ) : string [ ] {
if ( ! input || ! input . trim ( ) ) return [ ] ;
return Array . from ( new Set ( input . split ( "," ) . map ( ( part ) = > part . trim ( ) ) . filter ( Boolean ) ) ) ;
}
2026-03-19 16:29:11 -05:00
export function isHttpUrl ( input : string ) : boolean {
2026-03-02 16:43:59 -06:00
return /^https?:\/\//i . test ( input . trim ( ) ) ;
}
2026-03-19 16:29:11 -05:00
export function isGithubUrl ( input : string ) : boolean {
2026-03-02 16:43:59 -06:00
return /^https?:\/\/github\.com\//i . test ( input . trim ( ) ) ;
}
2026-03-19 07:23:36 -05:00
async function collectPackageFiles (
root : string ,
current : string ,
files : Record < string , CompanyPortabilityFileEntry > ,
) : Promise < void > {
2026-03-13 22:29:30 -05:00
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 ;
}
2026-03-14 09:46:16 -05:00
if ( ! entry . isFile ( ) ) continue ;
const isMarkdown = entry . name . endsWith ( ".md" ) ;
const isPaperclipYaml = entry . name === ".paperclip.yaml" || entry . name === ".paperclip.yml" ;
2026-03-19 07:23:36 -05:00
const contentType = binaryContentTypeByExtension [ path . extname ( entry . name ) . toLowerCase ( ) ] ;
if ( ! isMarkdown && ! isPaperclipYaml && ! contentType ) continue ;
2026-03-13 22:29:30 -05:00
const relativePath = path . relative ( root , absolutePath ) . replace ( /\\/g , "/" ) ;
2026-03-19 07:23:36 -05:00
files [ relativePath ] = readPortableFileEntry ( relativePath , await readFile ( absolutePath ) ) ;
2026-03-13 22:29:30 -05:00
}
}
2026-03-02 16:43:59 -06:00
async function resolveInlineSourceFromPath ( inputPath : string ) : Promise < {
2026-03-13 22:29:30 -05:00
rootPath : string ;
2026-03-19 07:23:36 -05:00
files : Record < string , CompanyPortabilityFileEntry > ;
2026-03-02 16:43:59 -06:00
} > {
const resolved = path . resolve ( inputPath ) ;
const resolvedStat = await stat ( resolved ) ;
2026-03-13 22:29:30 -05:00
const rootDir = resolvedStat . isDirectory ( ) ? resolved : path.dirname ( resolved ) ;
2026-03-19 07:23:36 -05:00
const files : Record < string , CompanyPortabilityFileEntry > = { } ;
2026-03-13 22:29:30 -05:00
await collectPackageFiles ( rootDir , rootDir , files ) ;
return {
rootPath : path.basename ( rootDir ) ,
files ,
} ;
2026-03-02 16:43:59 -06:00
}
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 } ) ;
2026-03-19 07:23:36 -05:00
const writeValue = portableFileEntryToWriteValue ( content ) ;
if ( typeof writeValue === "string" ) {
await writeFile ( filePath , writeValue , "utf8" ) ;
} else {
await writeFile ( filePath , writeValue ) ;
}
2026-03-02 16:43:59 -06:00
}
}
2026-03-14 18:59:26 -05:00
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." ) ;
}
}
2026-03-02 16:43:59 -06:00
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." ,
) ;
}
}
2026-02-20 07:10:58 -06:00
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 ) ;
}
} ) ,
) ;
2026-03-02 16:43:59 -06:00
addCommonClientOptions (
company
. command ( "export" )
2026-03-13 22:29:30 -05:00
. description ( "Export a company into a portable markdown package" )
2026-03-02 16:43:59 -06:00
. argument ( "<companyId>" , "Company ID" )
. requiredOption ( "--out <path>" , "Output directory" )
2026-03-20 06:20:30 -05:00
. option ( "--include <values>" , "Comma-separated include set: company,agents,projects,issues,tasks,skills" , "company,agents" )
2026-03-19 16:29:11 -05:00
. option ( "--skills <values>" , "Comma-separated skill slugs/keys to export" )
2026-03-14 09:46:16 -05:00
. 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" )
2026-03-14 18:59:26 -05:00
. option ( "--expand-referenced-skills" , "Vendor skill contents instead of exporting upstream references" , false )
2026-03-02 16:43:59 -06:00
. 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 ` ,
2026-03-14 09:46:16 -05:00
{
include ,
2026-03-19 16:29:11 -05:00
skills : parseCsvValues ( opts . skills ) ,
2026-03-14 09:46:16 -05:00
projects : parseCsvValues ( opts . projects ) ,
issues : parseCsvValues ( opts . issues ) ,
projectIssues : parseCsvValues ( opts . projectIssues ) ,
2026-03-14 18:59:26 -05:00
expandReferencedSkills : Boolean ( opts . expandReferencedSkills ) ,
2026-03-14 09:46:16 -05:00
} ,
2026-03-02 16:43:59 -06:00
) ;
if ( ! exported ) {
throw new Error ( "Export request returned no data" ) ;
}
2026-03-14 18:59:26 -05:00
await confirmOverwriteExportDirectory ( opts . out ! ) ;
2026-03-02 16:43:59 -06:00
await writeExportToFolder ( opts . out ! , exported ) ;
printOutput (
{
ok : true ,
out : path.resolve ( opts . out ! ) ,
2026-03-13 22:29:30 -05:00
rootPath : exported.rootPath ,
filesWritten : Object.keys ( exported . files ) . length ,
2026-03-14 09:46:16 -05:00
paperclipExtensionPath : exported.paperclipExtensionPath ,
2026-03-02 16:43:59 -06:00
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" )
2026-03-13 22:29:30 -05:00
. description ( "Import a portable markdown company package from local path, URL, or GitHub" )
2026-03-02 16:43:59 -06:00
. requiredOption ( "--from <pathOrUrl>" , "Source path or URL" )
2026-03-20 06:20:30 -05:00
. option ( "--include <values>" , "Comma-separated include set: company,agents,projects,issues,tasks,skills" , "company,agents" )
2026-03-02 16:43:59 -06:00
. 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 :
2026-03-19 07:23:36 -05:00
| { type : "inline" ; rootPath? : string | null ; files : Record < string , CompanyPortabilityFileEntry > }
2026-03-02 16:43:59 -06:00
| { type : "github" ; url : string } ;
if ( isHttpUrl ( from ) ) {
2026-03-19 16:29:11 -05:00
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 } ;
2026-03-02 16:43:59 -06:00
} else {
const inline = await resolveInlineSourceFromPath ( from ) ;
sourcePayload = {
type : "inline" ,
2026-03-13 22:29:30 -05:00
rootPath : inline.rootPath ,
2026-03-02 16:43:59 -06:00
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 ) ;
}
} ) ,
) ;
2026-02-20 07:10:58 -06:00
}