UNPKG

captan

Version:

Captan — Command your ownership. A tiny, hackable CLI cap table tool.

250 lines 8.97 kB
import { input, select, confirm, number } from '@inquirer/prompts'; import { getEntityDefaults } from './model.js'; import { randomUUID } from 'node:crypto'; export async function runInitWizard() { console.log('\n🧭 Captan Initialization Wizard\n'); // Company basics const name = await input({ message: 'Company name:', default: 'Acme, Inc.', }); const formationDate = await input({ message: 'Incorporation date (YYYY-MM-DD):', default: new Date().toISOString().slice(0, 10), }); const entityType = (await select({ message: 'Entity type:', choices: [ { value: 'C_CORP', name: 'C-Corporation (standard for VC-backed startups)' }, { value: 'S_CORP', name: 'S-Corporation' }, { value: 'LLC', name: 'LLC (Limited Liability Company)' }, ], })); const defaults = getEntityDefaults(entityType); const isCorp = entityType === 'C_CORP' || entityType === 'S_CORP'; const jurisdiction = await input({ message: 'State of incorporation:', default: 'DE', }); const currency = await input({ message: 'Currency:', default: 'USD', }); // Shares/Units const authorized = await number({ message: `Authorized ${defaults.unitsName.toLowerCase()}:`, default: defaults.authorized, validate: (val) => val !== undefined && Number.isInteger(val) && val > 0 ? true : `Authorized ${defaults.unitsName.toLowerCase()} must be a positive integer`, }); let parValue; if (isCorp) { parValue = await number({ message: 'Par value per share:', default: defaults.parValue, step: 'any', min: 0, validate: (val) => val === undefined || (typeof val === 'number' && val >= 0) ? true : 'Par value must be a non-negative number', }); } // Option pool let poolSize; let poolPct; const createPool = await confirm({ message: 'Create an option pool?', default: isCorp, }); if (createPool) { const poolType = await select({ message: 'How would you like to specify the pool size?', choices: [ { value: 'percent', name: 'As percentage of fully diluted equity' }, { value: 'absolute', name: `As absolute number of ${defaults.unitsName.toLowerCase()}` }, ], }); if (poolType === 'percent') { poolPct = await number({ message: `Pool percentage (e.g., ${defaults.poolPct} for ${defaults.poolPct}%):`, default: defaults.poolPct, step: 'any', min: 0, max: 99.999999, validate: (val) => val === undefined || (typeof val === 'number' && val >= 0 && val < 100) ? true : 'Pool percentage must be between 0 and 100 (exclusive)', }); } else { poolSize = await number({ message: `Number of ${defaults.unitsName.toLowerCase()} for pool:`, default: (() => { const baseAuthorized = authorized && authorized > 0 ? authorized : defaults.authorized; return Math.floor(baseAuthorized * (defaults.poolPct / 100)); })(), min: 0, validate: (val) => val === undefined || (typeof val === 'number' && val >= 0) ? true : 'Pool size must be a non-negative number', }); } } // Founders const founders = []; const addFounders = await confirm({ message: 'Add founders now?', default: true, }); if (addFounders) { let addMore = true; while (addMore) { const founderName = await input({ message: 'Founder name:', }); const founderEmail = await input({ message: 'Founder email (optional):', }); const founderShares = await number({ message: `Number of ${defaults.unitsName.toLowerCase()}:`, validate: (val) => val !== undefined && Number.isInteger(val) && val > 0 ? true : `Enter a positive integer number of ${defaults.unitsName.toLowerCase()}`, }); if (founderShares) { founders.push({ name: founderName, email: founderEmail?.trim() ? founderEmail.trim() : undefined, shares: founderShares, }); } addMore = await confirm({ message: 'Add another founder?', default: false, }); } } return { name, formationDate, entityType, jurisdiction, currency, authorized: authorized || defaults.authorized, parValue, poolSize, poolPct, founders, }; } export function parseFounderString(founderStr) { // Formats: // "Name:qty" // "Name:qty@pps" (pps ignored for simplicity) // "Name:email:qty" // "Name:email:qty@pps" const parts = founderStr.split(':'); if (parts.length === 2) { // "Name:qty" or "Name:qty@pps" const [name, qtyPart] = parts; const parsed = parseInt(qtyPart.split('@')[0].replace(/,/g, ''), 10); const qty = Number.isNaN(parsed) ? 0 : parsed; return { name: name.trim(), shares: qty }; } else if (parts.length === 3) { // "Name:email:qty" or "Name:email:qty@pps" const [name, email, qtyPart] = parts; const parsed = parseInt(qtyPart.split('@')[0].replace(/,/g, ''), 10); const qty = Number.isNaN(parsed) ? 0 : parsed; return { name: name.trim(), email: email.trim(), shares: qty, }; } throw new Error(`Invalid founder format: ${founderStr}`); } export function calculatePoolFromPercentage(founderShares, poolPct) { // Pool as percentage of fully diluted (founders + pool) // If founders have F shares and we want pool to be P% of total: // Pool / (Founders + Pool) = P/100 // Pool = (P/100) * (F + Pool) // Pool * (1 - P/100) = F * (P/100) // Pool = F * (P/100) / (1 - P/100) if (poolPct <= 0) return 0; if (poolPct >= 100 - Number.EPSILON) { // 100% pool is undefined (infinite); require explicit correction upstream return 0; } // Algebraic simplification avoids subtractive cancellation from (1 - P/100) return Math.floor((founderShares * poolPct) / (100 - poolPct)); } export function buildModelFromWizard(result) { const model = { version: 1, company: { id: `comp_${randomUUID()}`, name: result.name, formationDate: result.formationDate, entityType: result.entityType, jurisdiction: result.jurisdiction, currency: result.currency, }, stakeholders: [], securityClasses: [], issuances: [], optionGrants: [], safes: [], valuations: [], audit: [], }; const isCorp = result.entityType === 'C_CORP' || result.entityType === 'S_CORP'; // Add common stock/units class model.securityClasses.push({ id: 'sc_common', kind: 'COMMON', label: isCorp ? 'Common Stock' : 'Common Units', authorized: result.authorized, parValue: result.parValue, }); // Add founders let totalFounderShares = 0; for (const founder of result.founders) { const stakeholderId = `sh_${randomUUID()}`; model.stakeholders.push({ id: stakeholderId, type: 'person', name: founder.name, email: founder.email, }); if (founder.shares > 0) { model.issuances.push({ id: `is_${randomUUID()}`, securityClassId: 'sc_common', stakeholderId, qty: founder.shares, pps: result.parValue ?? 0, date: model.company.formationDate, }); totalFounderShares += founder.shares; } } // Calculate and add pool let poolQty = result.poolSize; if (!poolQty && result.poolPct && totalFounderShares > 0) { poolQty = calculatePoolFromPercentage(totalFounderShares, result.poolPct); } if (poolQty && poolQty > 0) { const currentYear = new Date().getFullYear(); model.securityClasses.push({ id: 'sc_pool', kind: 'OPTION_POOL', label: `${currentYear} Stock Option Plan`, authorized: poolQty, }); } return model; } //# sourceMappingURL=init-wizard.js.map