@startanaicompany/crm 2.8.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.js +753 -6
  2. package/package.json +2 -2
package/index.js CHANGED
@@ -212,6 +212,36 @@ keysCmd
212
212
  }
213
213
  });
214
214
 
215
+ // S20-1: set per-key rate limit (requires admin JWT auth)
216
+ keysCmd
217
+ .command('set-limit <key-id> <limit>')
218
+ .description('Set rate limit (req/min) for an API key — requires admin credentials')
219
+ .option('--email <email>', 'Admin email (or set SAAC_CRM_ADMIN_EMAIL env)')
220
+ .option('--password <password>', 'Admin password (or set SAAC_CRM_ADMIN_PASSWORD env)')
221
+ .action(async (keyId, limitStr, opts) => {
222
+ const globalOpts = program.opts();
223
+ const apiUrl = resolveApiUrl(globalOpts.url);
224
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
225
+ const limit = parseInt(limitStr, 10);
226
+ if (isNaN(limit) || limit < 1 || limit > 10000) {
227
+ console.error('Error: limit must be an integer between 1 and 10000');
228
+ process.exit(1);
229
+ }
230
+ const cfg = loadConfig();
231
+ const email = opts.email || process.env.SAAC_CRM_ADMIN_EMAIL || cfg.admin_email;
232
+ const password = opts.password || process.env.SAAC_CRM_ADMIN_PASSWORD || await promptSecret('Admin password: ');
233
+ try {
234
+ const base = apiUrl.replace(/\/$/, '');
235
+ const loginRes = await axios.post(`${base}/api/v1/admin/login`, { email, password });
236
+ const token = loginRes.data.data?.token;
237
+ const res = await axios.patch(`${base}/api/v1/admin/keys/${keyId}`, { rate_limit: limit }, {
238
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
239
+ });
240
+ console.log(`Rate limit updated to ${limit} req/min.`);
241
+ printJSON(res.data.data);
242
+ } catch (err) { handleError(err); }
243
+ });
244
+
215
245
  // ============================================================
216
246
  // REGISTER — first-time workspace + admin key setup
217
247
  // NOTE: For agent self-service (zero-auth), use: saac_crm keys create --name <name>
@@ -398,6 +428,10 @@ leadsCmd
398
428
  .option('--source <source>', 'Source: api|import|referral|linkedin|cold_email|website|inbound|partner|event|other', 'api')
399
429
  .option('--deal-value <amount>', 'Deal value (numeric)')
400
430
  .option('--pipeline-stage <stage>', 'Pipeline stage: new|prospecting|discovery|qualified|demo_scheduled|demo_completed|proposal_sent|negotiation|contract_sent|closed_won|closed_lost|no_decision|dormant')
431
+ .option('--score <n>', 'Lead score (0-100 integer). Conventions: 0-19 Cold, 20-39 Warm, 40-59 Developing, 60-79 Qualified, 80-100 Hot')
432
+ .option('--score-reason <text>', 'Score reason/explanation')
433
+ .option('--close-probability <n>', 'Close probability 0-100 integer')
434
+ .option('--close-reason <reason>', `Close reason (${['price','timing','competition','no_budget','no_decision','fit','other'].join('|')})`)
401
435
  .option('--notes <notes>', 'Notes')
402
436
  .option('--assigned-to <assignedTo>', 'Assigned to')
403
437
  .option('--tag <tag>', 'Tag (repeatable)', (v, prev) => prev.concat([v]), [])
@@ -419,6 +453,11 @@ leadsCmd
419
453
  source: opts.source,
420
454
  ...(opts.dealValue !== undefined && { deal_value: parseFloat(opts.dealValue) }),
421
455
  ...(opts.pipelineStage && { pipeline_stage: opts.pipelineStage }),
456
+ // --score 0 is valid — use !== undefined not falsy check
457
+ ...(opts.score !== undefined && { score: parseInt(opts.score) }),
458
+ ...(opts.scoreReason && { score_reason: opts.scoreReason }),
459
+ ...(opts.closeProbability !== undefined && { close_probability: parseInt(opts.closeProbability) }),
460
+ ...(opts.closeReason && { close_reason: opts.closeReason }),
422
461
  ...(opts.notes && { notes: opts.notes }),
423
462
  ...(opts.assignedTo && { assigned_to: opts.assignedTo }),
424
463
  ...(opts.tag.length > 0 && { tags: opts.tag }),
@@ -448,6 +487,9 @@ leadsCmd
448
487
  .option('--order <dir>', 'Sort direction: asc|desc', 'desc')
449
488
  .option('--score-min <n>', 'Filter leads with score >= n (0-100)')
450
489
  .option('--score-max <n>', 'Filter leads with score <= n (0-100)')
490
+ .option('--close-probability-min <n>', 'Filter leads with close_probability >= n')
491
+ .option('--close-probability-max <n>', 'Filter leads with close_probability <= n')
492
+ .option('--not-contacted-since-days <n>', 'Filter stale leads not contacted in N days (excludes converted/lost)')
451
493
  .option('--created-after <date>', 'Filter leads created after this ISO8601 date (e.g. 2026-01-01T00:00:00Z)')
452
494
  .option('--created-before <date>', 'Filter leads created before this ISO8601 date')
453
495
  .option('--updated-after <date>', 'Filter leads updated after this ISO8601 date')
@@ -471,6 +513,9 @@ leadsCmd
471
513
  ...(opts.order && { order: opts.order }),
472
514
  ...(opts.scoreMin !== undefined && { score_min: opts.scoreMin }),
473
515
  ...(opts.scoreMax !== undefined && { score_max: opts.scoreMax }),
516
+ ...(opts.closeProbabilityMin !== undefined && { close_probability_min: opts.closeProbabilityMin }),
517
+ ...(opts.closeProbabilityMax !== undefined && { close_probability_max: opts.closeProbabilityMax }),
518
+ ...(opts.notContactedSinceDays !== undefined && { not_contacted_since_days: opts.notContactedSinceDays }),
474
519
  ...(opts.createdAfter && { created_after: opts.createdAfter }),
475
520
  ...(opts.createdBefore && { created_before: opts.createdBefore }),
476
521
  ...(opts.updatedAfter && { updated_after: opts.updatedAfter }),
@@ -563,6 +608,10 @@ leadsCmd
563
608
  .option('--status <status>', 'New status')
564
609
  .option('--source <source>', 'New source')
565
610
  .option('--deal-value <amount>', 'Deal value (numeric)')
611
+ .option('--close-probability <n>', 'Close probability 0-100')
612
+ .option('--close-reason <reason>', 'Close reason (price|timing|competition|no_budget|no_decision|fit|other)')
613
+ .option('--expected-close-date <date>', 'Expected close date (YYYY-MM-DD)')
614
+ .option('--score <n>', 'Lead score 0-100 (calls PATCH /leads/:id/score)')
566
615
  .option('--notes <notes>', 'New notes')
567
616
  .option('--assigned-to <assignedTo>', 'New assigned-to')
568
617
  .option('--tag <tag>', 'Replace ALL tags (repeatable). Use --add-tag / --remove-tag for surgical edits.', (v, prev) => prev.concat([v]), [])
@@ -570,9 +619,11 @@ leadsCmd
570
619
  .option('--remove-tag <tag>', 'Remove a single tag (repeatable)', (v, prev) => prev.concat([v]), [])
571
620
  .option('--external-id <externalId>', 'New external ID')
572
621
  .option('--version <version>', 'Optimistic lock version')
622
+ .option('--from-agent-name <name>', 'Agent name for attribution (falls back to config defaultAgentName)')
573
623
  .action(async (id, opts) => {
574
624
  const globalOpts = program.opts();
575
- const client = getClient(globalOpts);
625
+ const agentName = resolveAgentName(opts.fromAgentName);
626
+ const client = getClient(globalOpts, agentName);
576
627
  try {
577
628
  // Surgical tag ops: call PATCH /leads/:id/tags first if add/remove specified
578
629
  if (opts.addTag.length > 0 || opts.removeTag.length > 0) {
@@ -583,14 +634,19 @@ leadsCmd
583
634
  // If no other fields to update, return tag result
584
635
  const hasOtherFields = opts.name || opts.email || opts.phone !== undefined ||
585
636
  opts.company !== undefined || opts.status || opts.source ||
586
- opts.dealValue !== undefined || opts.notes !== undefined ||
587
- opts.assignedTo !== undefined || opts.tag.length > 0 ||
588
- opts.externalId !== undefined;
637
+ opts.dealValue !== undefined || opts.closeProbability !== undefined ||
638
+ opts.closeReason !== undefined || opts.expectedCloseDate !== undefined ||
639
+ opts.notes !== undefined || opts.assignedTo !== undefined ||
640
+ opts.tag.length > 0 || opts.externalId !== undefined || opts.score !== undefined;
589
641
  if (!hasOtherFields) {
590
642
  printJSON(tagRes.data);
591
643
  return;
592
644
  }
593
645
  }
646
+ // Score update: call separate PATCH /leads/:id/score endpoint
647
+ if (opts.score !== undefined) {
648
+ await client.patch(`/leads/${id}/score`, { score: parseInt(opts.score) });
649
+ }
594
650
  const body = {
595
651
  ...(opts.name && { name: opts.name }),
596
652
  ...(opts.email && { email: opts.email }),
@@ -599,14 +655,22 @@ leadsCmd
599
655
  ...(opts.status && { status: opts.status }),
600
656
  ...(opts.source && { source: opts.source }),
601
657
  ...(opts.dealValue !== undefined && { deal_value: parseFloat(opts.dealValue) }),
658
+ ...(opts.closeProbability !== undefined && { close_probability: parseInt(opts.closeProbability) }),
659
+ ...(opts.closeReason !== undefined && { close_reason: opts.closeReason }),
660
+ ...(opts.expectedCloseDate !== undefined && { expected_close_date: opts.expectedCloseDate }),
602
661
  ...(opts.notes !== undefined && { notes: opts.notes }),
603
662
  ...(opts.assignedTo !== undefined && { assigned_to: opts.assignedTo }),
604
663
  ...(opts.tag.length > 0 && { tags: opts.tag }),
605
664
  ...(opts.externalId !== undefined && { external_id: opts.externalId }),
606
665
  ...(opts.version !== undefined && { version: parseInt(opts.version) })
607
666
  };
608
- const res = await client.put(`/leads/${id}`, body);
609
- printJSON(res.data);
667
+ // Only call PUT if there are fields beyond score/tags to update
668
+ if (Object.keys(body).length > 0) {
669
+ const res = await client.put(`/leads/${id}`, body);
670
+ printJSON(res.data);
671
+ } else if (opts.score !== undefined) {
672
+ console.log(JSON.stringify({ success: true, data: { scored: true, score: parseInt(opts.score) } }, null, 2));
673
+ }
610
674
  } catch (err) {
611
675
  handleError(err);
612
676
  }
@@ -627,6 +691,21 @@ leadsCmd
627
691
  }
628
692
  });
629
693
 
694
+ leadsCmd
695
+ .command('assign <id> <agent>')
696
+ .description('Assign a lead to an agent (fires lead.assigned webhook) — S18-2')
697
+ .action(async (id, agent) => {
698
+ const globalOpts = program.opts();
699
+ const client = getClient(globalOpts);
700
+ try {
701
+ const res = await client.patch(`/leads/${id}/assign`, { assigned_to: agent });
702
+ console.log(`Lead assigned to: ${res.data.data.assigned_to}`);
703
+ printJSON(res.data);
704
+ } catch (err) {
705
+ handleError(err);
706
+ }
707
+ });
708
+
630
709
  leadsCmd
631
710
  .command('history <id>')
632
711
  .description('Show status change history for a lead')
@@ -641,6 +720,146 @@ leadsCmd
641
720
  }
642
721
  });
643
722
 
723
+ leadsCmd
724
+ .command('bulk-update')
725
+ .description('Bulk update lead status using filter criteria (S17-1). Up to 200 leads per call.')
726
+ .option('--filter-status <status>', 'Only update leads with this current status')
727
+ .option('--filter-source <source>', 'Only update leads with this source')
728
+ .option('--filter-assigned-to <agent>', 'Only update leads assigned to this agent')
729
+ .requiredOption('--status <status>', 'New status to set (new|contacted|qualified|unresponsive|converted|lost)')
730
+ .option('--close-reason <reason>', 'Close reason (required when --status lost): price|timing|competition|no_budget|no_decision|fit|other')
731
+ .option('--dry-run', 'Preview count without applying changes', false)
732
+ .action(async (opts) => {
733
+ const globalOpts = program.opts();
734
+ const client = getClient(globalOpts);
735
+ const filter = {};
736
+ if (opts.filterStatus) filter.status = opts.filterStatus;
737
+ if (opts.filterSource) filter.source = opts.filterSource;
738
+ if (opts.filterAssignedTo) filter.assigned_to = opts.filterAssignedTo;
739
+ const update = { status: opts.status };
740
+ if (opts.closeReason) update.close_reason = opts.closeReason;
741
+ try {
742
+ const res = await client.patch('/leads/bulk', { filter, update, dry_run: opts.dryRun });
743
+ if (opts.dryRun) {
744
+ console.log(`Dry run: ${res.data.data.would_update} lead(s) would be updated.`);
745
+ } else {
746
+ console.log(`Updated: ${res.data.data.updated} lead(s).`);
747
+ if (res.data.data.failed && res.data.data.failed.length > 0) {
748
+ console.log(`Failed: ${res.data.data.failed.length}`);
749
+ }
750
+ }
751
+ printJSON(res.data);
752
+ } catch (err) {
753
+ handleError(err);
754
+ }
755
+ });
756
+
757
+ // leads search <term> — S19-1
758
+ leadsCmd
759
+ .command('search <term>')
760
+ .description('Search leads by name, email, company, or notes')
761
+ .action(async (term) => {
762
+ const globalOpts = program.opts();
763
+ const client = getClient(globalOpts);
764
+ try {
765
+ const res = await client.get('/leads', { params: { q: term, per_page: 50 } });
766
+ const leads = res.data.data || [];
767
+ if (leads.length === 0) {
768
+ console.log('No leads found matching: ' + term);
769
+ return;
770
+ }
771
+ const rows = leads.map(l => ({
772
+ id: l.id,
773
+ name: l.name,
774
+ email: l.email,
775
+ company: l.company || '—',
776
+ status: l.status
777
+ }));
778
+ console.table(rows);
779
+ } catch (err) {
780
+ handleError(err);
781
+ }
782
+ });
783
+
784
+ // leads dedupe <id> — S19-3
785
+ leadsCmd
786
+ .command('dedupe <id>')
787
+ .description('Find duplicate leads for a given lead ID')
788
+ .action(async (id) => {
789
+ const globalOpts = program.opts();
790
+ const client = getClient(globalOpts);
791
+ try {
792
+ const leadRes = await client.get(`/leads/${id}`);
793
+ const lead = leadRes.data.data;
794
+ const body = {};
795
+ if (lead.email) body.email = lead.email;
796
+ if (lead.name) body.name = lead.name;
797
+ if (lead.company) body.company = lead.company;
798
+ const dedupeRes = await client.post('/leads/dedupe/check', body);
799
+ const duplicates = (dedupeRes.data.data && dedupeRes.data.data.duplicates) || [];
800
+ // Filter out the lead itself
801
+ const matches = duplicates.filter(d => d.id !== id);
802
+ if (matches.length === 0) {
803
+ console.log('No duplicate leads found for: ' + lead.name);
804
+ return;
805
+ }
806
+ console.log(`Potential duplicates for "${lead.name}" (${id}):`);
807
+ const rows = matches.map(d => ({
808
+ id: d.id,
809
+ name: d.name,
810
+ email: d.email,
811
+ company: d.company || '—',
812
+ status: d.status,
813
+ score: d.score
814
+ }));
815
+ console.table(rows);
816
+ } catch (err) {
817
+ handleError(err);
818
+ }
819
+ });
820
+
821
+
822
+ // S21-1: leads tag / leads untag
823
+ leadsCmd
824
+ .command('tag <lead-id> <tag>')
825
+ .description('Add a tag to a lead')
826
+ .action(async (leadId, tag, opts) => {
827
+ const globalOpts = program.opts();
828
+ const client = getClient(globalOpts);
829
+ try {
830
+ const res = await client.patch(`/leads/${leadId}/tags`, { add: [tag] });
831
+ printJSON(res.data);
832
+ } catch (err) { handleError(err); }
833
+ });
834
+
835
+ leadsCmd
836
+ .command('untag <lead-id> <tag>')
837
+ .description('Remove a tag from a lead')
838
+ .action(async (leadId, tag, opts) => {
839
+ const globalOpts = program.opts();
840
+ const client = getClient(globalOpts);
841
+ try {
842
+ const res = await client.patch(`/leads/${leadId}/tags`, { remove: [tag] });
843
+ printJSON(res.data);
844
+ } catch (err) { handleError(err); }
845
+ });
846
+
847
+ leadsCmd
848
+ .command('audit <id>')
849
+ .description('Show audit trail for a lead')
850
+ .action(async (id) => {
851
+ const globalOpts = program.opts();
852
+ const client = getClient(globalOpts);
853
+ try {
854
+ const res = await client.get(`/leads/${id}/audit`);
855
+ const events = res.data.data || [];
856
+ if (events.length === 0) { console.log('No audit events found.'); return; }
857
+ for (const e of events) {
858
+ console.log(`[${new Date(e.changed_at).toLocaleString()}] ${e.field}: ${e.old_value ?? '—'} → ${e.new_value ?? '—'} (by ${e.changed_by})`);
859
+ }
860
+ } catch (err) { handleError(err); }
861
+ });
862
+
644
863
  // ============================================================
645
864
  // USERS COMMANDS (requires admin scope key)
646
865
  // ============================================================
@@ -1344,7 +1563,42 @@ emailsCmd
1344
1563
  }
1345
1564
  });
1346
1565
 
1566
+ emailsCmd
1567
+ .command('sync')
1568
+ .description('Sync Gmail inbox into CRM (deduped by Gmail message ID)')
1569
+ .option('--max-results <n>', 'Max number of messages to fetch (default 100, max 500)', '100')
1570
+ .action(async (opts) => {
1571
+ const globalOpts = program.opts();
1572
+ const client = getClient(globalOpts);
1573
+ try {
1574
+ const res = await client.post('/emails/sync', { max_results: parseInt(opts.maxResults) || 100 });
1575
+ printJSON(res.data);
1576
+ } catch (err) {
1577
+ handleError(err);
1578
+ }
1579
+ });
1347
1580
 
1581
+ emailsCmd
1582
+ .command('send')
1583
+ .description('Send an email via Gmail (rate-limited: 1/30min, 30/day per workspace)')
1584
+ .requiredOption('--to <email>', 'Recipient email address')
1585
+ .requiredOption('--subject <subject>', 'Email subject')
1586
+ .requiredOption('--body <text>', 'Email body text')
1587
+ .option('--lead-id <id>', 'Link sent email to a lead ID')
1588
+ .option('--dry-run', 'Validate only — do not send or consume rate limit quota')
1589
+ .action(async (opts) => {
1590
+ const globalOpts = program.opts();
1591
+ const client = getClient(globalOpts);
1592
+ try {
1593
+ const body = { to: opts.to, subject: opts.subject, body: opts.body };
1594
+ if (opts.leadId) body.lead_id = opts.leadId;
1595
+ if (opts.dryRun) body.dry_run = true;
1596
+ const res = await client.post('/emails/send', body);
1597
+ printJSON(res.data);
1598
+ } catch (err) {
1599
+ handleError(err);
1600
+ }
1601
+ });
1348
1602
 
1349
1603
  // ============================================================
1350
1604
  // QUOTES commands
@@ -2443,4 +2697,497 @@ const supportCmd = program.command('support').description('Submit and manage bug
2443
2697
  });
2444
2698
  });
2445
2699
 
2700
+ // ============================================================
2701
+ // EMAIL SYNC CONFIG COMMANDS (Sprint 16)
2702
+ // ============================================================
2703
+
2704
+ const emailCmd = program.command('email-config').description('Persistent workspace email sync settings (Sprint 16)');
2705
+
2706
+ emailCmd
2707
+ .command('status')
2708
+ .description('Show current workspace email sync configuration and defaults')
2709
+ .action(async () => {
2710
+ const globalOpts = program.opts();
2711
+ const client = getClient(globalOpts);
2712
+ try {
2713
+ const res = await client.get('/emails/sync/status');
2714
+ const d = res.data.data;
2715
+ console.log('\n=== Email Sync Config ===');
2716
+ console.log(`Gmail configured: ${d.gmail_configured}`);
2717
+ console.log(`Workspace: ${d.workspace_slug}`);
2718
+ console.log(`auto_create_inbound_leads default: ${d.auto_create_inbound_leads}`);
2719
+ } catch (err) {
2720
+ handleError(err);
2721
+ }
2722
+ });
2723
+
2724
+ emailCmd
2725
+ .command('set')
2726
+ .description('Set persistent workspace default for auto_create_inbound_leads')
2727
+ .requiredOption('--auto-create-leads <bool>', 'Set workspace default: true or false')
2728
+ .action(async (opts) => {
2729
+ const globalOpts = program.opts();
2730
+ const client = getClient(globalOpts);
2731
+ const value = opts.autoCreateLeads === 'true' || opts.autoCreateLeads === true;
2732
+ try {
2733
+ const res = await client.patch('/emails/sync/config', { auto_create_inbound_leads: value });
2734
+ console.log(`Workspace default auto_create_inbound_leads set to: ${res.data.data.auto_create_inbound_leads}`);
2735
+ } catch (err) {
2736
+ handleError(err);
2737
+ }
2738
+ });
2739
+
2740
+ // ============================================================
2741
+ // ANALYTICS COMMANDS (S17-2)
2742
+ // ============================================================
2743
+
2744
+ const analyticsCmd = program.command('analytics').description('CRM analytics and pipeline metrics (S17-2)');
2745
+
2746
+ analyticsCmd
2747
+ .command('summary')
2748
+ .description('Show aggregated lead analytics including pipeline velocity, loss reasons, and conversion rates')
2749
+ .option('--assigned-to <agent>', 'Scope metrics to a specific agent')
2750
+ .action(async (opts) => {
2751
+ const globalOpts = program.opts();
2752
+ const client = getClient(globalOpts);
2753
+ const params = {};
2754
+ if (opts.assignedTo) params.assigned_to = opts.assignedTo;
2755
+ try {
2756
+ const res = await client.get('/analytics/summary', { params });
2757
+ const d = res.data.data;
2758
+ console.log('\n=== Analytics Summary ===');
2759
+ console.log(`Total leads: ${d.total_leads}`);
2760
+ if (d.scoped_to) console.log(`Scoped to agent: ${d.scoped_to}`);
2761
+ console.log('\nBy Status:');
2762
+ Object.entries(d.by_status || {}).forEach(([k, v]) => console.log(` ${k}: ${v}`));
2763
+ console.log('\nBy Stage:');
2764
+ Object.entries(d.by_stage || {}).forEach(([k, v]) => console.log(` ${k}: ${v}`));
2765
+ if (d.avg_close_probability != null) console.log(`\nAvg close probability: ${d.avg_close_probability}%`);
2766
+ console.log(`Stale leads (30d no activity): ${d.stale_leads_count}`);
2767
+ if (Object.keys(d.loss_reason_distribution || {}).length > 0) {
2768
+ console.log('\nLoss Reasons:');
2769
+ Object.entries(d.loss_reason_distribution).forEach(([k, v]) => console.log(` ${k}: ${v}`));
2770
+ }
2771
+ if (Object.keys(d.pipeline_velocity || {}).length > 0) {
2772
+ console.log('\nPipeline Velocity (avg days per stage):');
2773
+ Object.entries(d.pipeline_velocity).forEach(([k, v]) => console.log(` ${k}: ${v}d`));
2774
+ }
2775
+ if (Object.keys(d.stage_conversion_rates || {}).length > 0) {
2776
+ console.log('\nStage Conversion Rates:');
2777
+ Object.entries(d.stage_conversion_rates).forEach(([k, v]) => console.log(` ${k}: ${v}%`));
2778
+ }
2779
+ } catch (err) {
2780
+ handleError(err);
2781
+ }
2782
+ });
2783
+
2784
+ // ============================================================
2785
+ // WEBHOOKS COMMANDS (S17-3)
2786
+ // ============================================================
2787
+
2788
+ const webhooksCmd = program.command('webhooks').description('Manage webhook endpoints (S17-3)');
2789
+
2790
+ webhooksCmd
2791
+ .command('list')
2792
+ .description('List registered webhook endpoints')
2793
+ .action(async () => {
2794
+ const globalOpts = program.opts();
2795
+ const client = getClient(globalOpts);
2796
+ try {
2797
+ const res = await client.get('/webhooks');
2798
+ printJSON(res.data);
2799
+ } catch (err) {
2800
+ handleError(err);
2801
+ }
2802
+ });
2803
+
2804
+ webhooksCmd
2805
+ .command('events')
2806
+ .description('List valid webhook event types')
2807
+ .action(async () => {
2808
+ const globalOpts = program.opts();
2809
+ const client = getClient(globalOpts);
2810
+ try {
2811
+ const res = await client.get('/webhooks/events');
2812
+ printJSON(res.data);
2813
+ } catch (err) {
2814
+ handleError(err);
2815
+ }
2816
+ });
2817
+
2818
+ webhooksCmd
2819
+ .command('create')
2820
+ .description('Register a new webhook endpoint')
2821
+ .requiredOption('--url <url>', 'Webhook destination URL')
2822
+ .requiredOption('--events <events>', 'Comma-separated event types (e.g. lead.created,lead.status_changed)')
2823
+ .option('--secret <secret>', 'Signing secret for HMAC-SHA256 verification (generated if omitted)')
2824
+ .action(async (opts) => {
2825
+ const globalOpts = program.opts();
2826
+ const client = getClient(globalOpts);
2827
+ const events = opts.events.split(',').map(e => e.trim()).filter(Boolean);
2828
+ const body = { url: opts.url, events };
2829
+ if (opts.secret) body.secret = opts.secret;
2830
+ try {
2831
+ const res = await client.post('/webhooks', body);
2832
+ console.log('Webhook registered.');
2833
+ printJSON(res.data);
2834
+ } catch (err) {
2835
+ handleError(err);
2836
+ }
2837
+ });
2838
+
2839
+ webhooksCmd
2840
+ .command('delete <id>')
2841
+ .description('Remove a webhook endpoint by ID')
2842
+ .action(async (id) => {
2843
+ const globalOpts = program.opts();
2844
+ const client = getClient(globalOpts);
2845
+ try {
2846
+ const res = await client.delete(`/webhooks/${id}`);
2847
+ console.log('Webhook deleted.');
2848
+ printJSON(res.data);
2849
+ } catch (err) {
2850
+ handleError(err);
2851
+ }
2852
+ });
2853
+
2854
+ // ── stages ──────────────────────────────────────────────────────────
2855
+ const stagesCmd = program.command('stages').description('Manage pipeline stages (admin)');
2856
+
2857
+ // Helper: login via workspace-scoped POST /auth/login and return JWT token
2858
+ async function stagesAdminLogin(base, cfg, opts) {
2859
+ const workspace = opts.workspace || process.env.SAAC_CRM_WORKSPACE || cfg.workspace;
2860
+ const email = opts.email || process.env.SAAC_CRM_ADMIN_EMAIL || cfg.admin_email;
2861
+ const password = opts.password || process.env.SAAC_CRM_ADMIN_PASSWORD || await promptSecret('Admin password: ');
2862
+ if (!workspace) { console.error('Error: workspace required. Use --workspace or run: saac_crm config set --workspace <slug>'); process.exit(1); }
2863
+ if (!email) { console.error('Error: email required. Use --email or SAAC_CRM_ADMIN_EMAIL'); process.exit(1); }
2864
+ const loginRes = await axios.post(`${base}/api/v1/auth/login`, { workspace, email, password });
2865
+ return loginRes.data.data?.token;
2866
+ }
2867
+
2868
+ stagesCmd
2869
+ .command('list')
2870
+ .description('List all pipeline stages for workspace')
2871
+ .option('--workspace <slug>', 'Workspace slug')
2872
+ .option('--email <email>', 'Admin email')
2873
+ .option('--password <password>', 'Admin password')
2874
+ .action(async (opts) => {
2875
+ const globalOpts = program.opts();
2876
+ const apiUrl = resolveApiUrl(globalOpts.url);
2877
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
2878
+ const cfg = loadConfig();
2879
+ try {
2880
+ const base = apiUrl.replace(/\/$/, '');
2881
+ const token = await stagesAdminLogin(base, cfg, opts);
2882
+ const res = await axios.get(`${base}/api/v1/admin/stages`, {
2883
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
2884
+ });
2885
+ printJSON(res.data);
2886
+ } catch (err) { handleError(err); }
2887
+ });
2888
+
2889
+ stagesCmd
2890
+ .command('create <name>')
2891
+ .description('Create a new pipeline stage')
2892
+ .option('--order <n>', 'Sort order (integer)', '999')
2893
+ .option('--workspace <slug>', 'Workspace slug')
2894
+ .option('--email <email>', 'Admin email')
2895
+ .option('--password <password>', 'Admin password')
2896
+ .action(async (name, opts) => {
2897
+ const globalOpts = program.opts();
2898
+ const apiUrl = resolveApiUrl(globalOpts.url);
2899
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
2900
+ const cfg = loadConfig();
2901
+ try {
2902
+ const base = apiUrl.replace(/\/$/, '');
2903
+ const token = await stagesAdminLogin(base, cfg, opts);
2904
+ const res = await axios.post(`${base}/api/v1/admin/stages`, { name, order: parseInt(opts.order, 10) }, {
2905
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
2906
+ });
2907
+ printJSON(res.data);
2908
+ } catch (err) { handleError(err); }
2909
+ });
2910
+
2911
+ stagesCmd
2912
+ .command('update <stage-id>')
2913
+ .description('Rename or reorder a pipeline stage')
2914
+ .option('--name <name>', 'New stage name')
2915
+ .option('--order <n>', 'New sort order (integer)')
2916
+ .option('--workspace <slug>', 'Workspace slug')
2917
+ .option('--email <email>', 'Admin email')
2918
+ .option('--password <password>', 'Admin password')
2919
+ .action(async (stageId, opts) => {
2920
+ const globalOpts = program.opts();
2921
+ const apiUrl = resolveApiUrl(globalOpts.url);
2922
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
2923
+ const cfg = loadConfig();
2924
+ const body = {};
2925
+ if (opts.name) body.name = opts.name;
2926
+ if (opts.order !== undefined) body.order = parseInt(opts.order, 10);
2927
+ if (Object.keys(body).length === 0) { console.error('Provide --name and/or --order'); process.exit(1); }
2928
+ try {
2929
+ const base = apiUrl.replace(/\/$/, '');
2930
+ const token = await stagesAdminLogin(base, cfg, opts);
2931
+ const res = await axios.patch(`${base}/api/v1/admin/stages/${stageId}`, body, {
2932
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
2933
+ });
2934
+ printJSON(res.data);
2935
+ } catch (err) { handleError(err); }
2936
+ });
2937
+
2938
+ stagesCmd
2939
+ .command('delete <stage-id>')
2940
+ .description('Delete a pipeline stage (fails if leads are in that stage)')
2941
+ .option('--workspace <slug>', 'Workspace slug')
2942
+ .option('--email <email>', 'Admin email')
2943
+ .option('--password <password>', 'Admin password')
2944
+ .action(async (stageId, opts) => {
2945
+ const globalOpts = program.opts();
2946
+ const apiUrl = resolveApiUrl(globalOpts.url);
2947
+ if (!apiUrl) { console.error('Error: API URL not configured.'); process.exit(1); }
2948
+ const cfg = loadConfig();
2949
+ try {
2950
+ const base = apiUrl.replace(/\/$/, '');
2951
+ const token = await stagesAdminLogin(base, cfg, opts);
2952
+ const res = await axios.delete(`${base}/api/v1/admin/stages/${stageId}`, {
2953
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }
2954
+ });
2955
+ printJSON(res.data);
2956
+ } catch (err) { handleError(err); }
2957
+ });
2958
+
2959
+ // ============================================================
2960
+ // Sprint 49 T1-B: companies command group
2961
+ // ============================================================
2962
+ const companiesCmd = program.command('companies').description('Manage companies/accounts (S49-T1-B)');
2963
+
2964
+ companiesCmd
2965
+ .command('list')
2966
+ .description('List companies')
2967
+ .option('--q <query>', 'Search by name or domain')
2968
+ .option('--limit <n>', 'Max results', '20')
2969
+ .option('--offset <n>', 'Offset for pagination', '0')
2970
+ .action(async (opts) => {
2971
+ const globalOpts = program.opts();
2972
+ const client = getClient(globalOpts);
2973
+ try {
2974
+ const params = {};
2975
+ if (opts.q) params.q = opts.q;
2976
+ if (opts.limit) params.limit = opts.limit;
2977
+ if (opts.offset) params.offset = opts.offset;
2978
+ const res = await client.get('/companies', { params });
2979
+ printJSON(res.data);
2980
+ } catch (err) { handleError(err); }
2981
+ });
2982
+
2983
+ companiesCmd
2984
+ .command('get <id>')
2985
+ .description('Get a company by ID (includes linked leads)')
2986
+ .action(async (id) => {
2987
+ const globalOpts = program.opts();
2988
+ const client = getClient(globalOpts);
2989
+ try {
2990
+ const res = await client.get(`/companies/${id}`);
2991
+ printJSON(res.data);
2992
+ } catch (err) { handleError(err); }
2993
+ });
2994
+
2995
+ companiesCmd
2996
+ .command('create')
2997
+ .description('Create a new company')
2998
+ .requiredOption('--name <name>', 'Company name')
2999
+ .option('--domain <domain>', 'Company domain (e.g. acme.com)')
3000
+ .option('--industry <industry>', 'Industry')
3001
+ .option('--employee-count <n>', 'Number of employees')
3002
+ .option('--website <url>', 'Website URL')
3003
+ .option('--from-agent-name <name>', 'Agent name for attribution')
3004
+ .action(async (opts) => {
3005
+ const globalOpts = program.opts();
3006
+ const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
3007
+ try {
3008
+ const body = {
3009
+ name: opts.name,
3010
+ ...(opts.domain && { domain: opts.domain }),
3011
+ ...(opts.industry && { industry: opts.industry }),
3012
+ ...(opts.employeeCount !== undefined && { employee_count: parseInt(opts.employeeCount) }),
3013
+ ...(opts.website && { website: opts.website }),
3014
+ };
3015
+ const res = await client.post('/companies', body);
3016
+ printJSON(res.data);
3017
+ } catch (err) { handleError(err); }
3018
+ });
3019
+
3020
+ companiesCmd
3021
+ .command('update <id>')
3022
+ .description('Update a company by ID')
3023
+ .option('--name <name>', 'New name')
3024
+ .option('--domain <domain>', 'New domain')
3025
+ .option('--industry <industry>', 'New industry')
3026
+ .option('--employee-count <n>', 'New employee count')
3027
+ .option('--website <url>', 'New website URL')
3028
+ .option('--from-agent-name <name>', 'Agent name for attribution')
3029
+ .action(async (id, opts) => {
3030
+ const globalOpts = program.opts();
3031
+ const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
3032
+ try {
3033
+ const body = {
3034
+ ...(opts.name && { name: opts.name }),
3035
+ ...(opts.domain !== undefined && { domain: opts.domain }),
3036
+ ...(opts.industry !== undefined && { industry: opts.industry }),
3037
+ ...(opts.employeeCount !== undefined && { employee_count: parseInt(opts.employeeCount) }),
3038
+ ...(opts.website !== undefined && { website: opts.website }),
3039
+ };
3040
+ const res = await client.put(`/companies/${id}`, body);
3041
+ printJSON(res.data);
3042
+ } catch (err) { handleError(err); }
3043
+ });
3044
+
3045
+ companiesCmd
3046
+ .command('delete <id>')
3047
+ .description('Delete a company (soft delete, unlinks leads)')
3048
+ .action(async (id) => {
3049
+ const globalOpts = program.opts();
3050
+ const client = getClient(globalOpts);
3051
+ try {
3052
+ const res = await client.delete(`/companies/${id}`);
3053
+ printJSON(res.data);
3054
+ } catch (err) { handleError(err); }
3055
+ });
3056
+
3057
+ companiesCmd
3058
+ .command('leads <id>')
3059
+ .description('List leads linked to a company')
3060
+ .action(async (id) => {
3061
+ const globalOpts = program.opts();
3062
+ const client = getClient(globalOpts);
3063
+ try {
3064
+ const res = await client.get(`/companies/${id}`);
3065
+ // Return only the leads array from the company response
3066
+ const data = res.data?.data;
3067
+ printJSON({ success: true, data: data?.leads || [] });
3068
+ } catch (err) { handleError(err); }
3069
+ });
3070
+
3071
+ // ============================================================
3072
+ // Sprint 49 T1-B: sequences command group (enrollment only — definitions are admin-only)
3073
+ // ============================================================
3074
+ const sequencesCmd = program.command('sequences').description('Manage sequence enrollments (S49-T1-B). Sequence definitions are admin-only.');
3075
+
3076
+ sequencesCmd
3077
+ .command('list')
3078
+ .description('List all sequences (read-only)')
3079
+ .action(async () => {
3080
+ const globalOpts = program.opts();
3081
+ const client = getClient(globalOpts);
3082
+ try {
3083
+ const res = await client.get('/sequences');
3084
+ printJSON(res.data);
3085
+ } catch (err) { handleError(err); }
3086
+ });
3087
+
3088
+ sequencesCmd
3089
+ .command('get <id>')
3090
+ .description('Get a sequence with its steps')
3091
+ .action(async (id) => {
3092
+ const globalOpts = program.opts();
3093
+ const client = getClient(globalOpts);
3094
+ try {
3095
+ const res = await client.get(`/sequences/${id}`);
3096
+ printJSON(res.data);
3097
+ } catch (err) { handleError(err); }
3098
+ });
3099
+
3100
+ sequencesCmd
3101
+ .command('enroll <sequenceId>')
3102
+ .description('Enroll one or more leads into a sequence')
3103
+ .requiredOption('--lead-ids <ids>', 'Comma-separated lead UUIDs')
3104
+ .option('--from-agent-name <name>', 'Agent name for attribution')
3105
+ .action(async (sequenceId, opts) => {
3106
+ const globalOpts = program.opts();
3107
+ const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
3108
+ try {
3109
+ const lead_ids = opts.leadIds.split(',').map(s => s.trim()).filter(Boolean);
3110
+ const res = await client.post(`/sequences/${sequenceId}/enroll`, { lead_ids });
3111
+ printJSON(res.data);
3112
+ } catch (err) { handleError(err); }
3113
+ });
3114
+
3115
+ sequencesCmd
3116
+ .command('unenroll <sequenceId> <leadId>')
3117
+ .description('Unenroll a single lead from a sequence')
3118
+ .action(async (sequenceId, leadId) => {
3119
+ const globalOpts = program.opts();
3120
+ const client = getClient(globalOpts);
3121
+ try {
3122
+ const res = await client.delete(`/sequences/${sequenceId}/enroll/${leadId}`);
3123
+ printJSON(res.data);
3124
+ } catch (err) { handleError(err); }
3125
+ });
3126
+
3127
+ sequencesCmd
3128
+ .command('enrollments <sequenceId>')
3129
+ .description('List enrollments for a sequence')
3130
+ .option('--status <status>', 'Filter by status (active|paused|completed|unenrolled)')
3131
+ .option('--q <query>', 'Search by lead name or email')
3132
+ .option('--limit <n>', 'Max results', '50')
3133
+ .option('--offset <n>', 'Offset for pagination', '0')
3134
+ .action(async (sequenceId, opts) => {
3135
+ const globalOpts = program.opts();
3136
+ const client = getClient(globalOpts);
3137
+ try {
3138
+ const params = { limit: opts.limit, offset: opts.offset };
3139
+ if (opts.status) params.status = opts.status;
3140
+ if (opts.q) params.q = opts.q;
3141
+ const res = await client.get(`/sequences/${sequenceId}/enrollments`, { params });
3142
+ printJSON(res.data);
3143
+ } catch (err) { handleError(err); }
3144
+ });
3145
+
3146
+ sequencesCmd
3147
+ .command('bulk-enroll <sequenceId>')
3148
+ .description('Bulk enroll leads into a sequence (≤50 sync, >50 returns job ID)')
3149
+ .option('--lead-ids <ids>', 'Comma-separated lead UUIDs (max 500)')
3150
+ .option('--segment-id <id>', 'Enroll all leads in a segment')
3151
+ .option('--search-status <status>', 'Enroll leads matching status filter')
3152
+ .option('--from-agent-name <name>', 'Agent name for attribution')
3153
+ .action(async (sequenceId, opts) => {
3154
+ const globalOpts = program.opts();
3155
+ const client = getClient(globalOpts, resolveAgentName(opts.fromAgentName));
3156
+ try {
3157
+ const body = {};
3158
+ if (opts.leadIds) body.lead_ids = opts.leadIds.split(',').map(s => s.trim()).filter(Boolean);
3159
+ else if (opts.segmentId) body.segment_id = opts.segmentId;
3160
+ else if (opts.searchStatus) body.search = { status: opts.searchStatus };
3161
+ else { console.error('One of --lead-ids, --segment-id, or --search-status required'); process.exit(1); }
3162
+ const res = await client.post(`/sequences/${sequenceId}/enroll/bulk`, body);
3163
+ printJSON(res.data);
3164
+ } catch (err) { handleError(err); }
3165
+ });
3166
+
3167
+ sequencesCmd
3168
+ .command('bulk-unenroll <sequenceId>')
3169
+ .description('Bulk unenroll leads from a sequence')
3170
+ .requiredOption('--lead-ids <ids>', 'Comma-separated lead UUIDs')
3171
+ .action(async (sequenceId, opts) => {
3172
+ const globalOpts = program.opts();
3173
+ const client = getClient(globalOpts);
3174
+ try {
3175
+ const lead_ids = opts.leadIds.split(',').map(s => s.trim()).filter(Boolean);
3176
+ const res = await client.post(`/sequences/${sequenceId}/unenroll/bulk`, { lead_ids });
3177
+ printJSON(res.data);
3178
+ } catch (err) { handleError(err); }
3179
+ });
3180
+
3181
+ sequencesCmd
3182
+ .command('job <jobId>')
3183
+ .description('Poll status of an async bulk-enroll job')
3184
+ .action(async (jobId) => {
3185
+ const globalOpts = program.opts();
3186
+ const client = getClient(globalOpts);
3187
+ try {
3188
+ const res = await client.get(`/jobs/${jobId}`);
3189
+ printJSON(res.data);
3190
+ } catch (err) { handleError(err); }
3191
+ });
3192
+
2446
3193
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@startanaicompany/crm",
3
- "version": "2.8.0",
4
- "description": "AI-first CRM CLI \u2014 manage leads and API keys from the terminal",
3
+ "version": "2.11.0",
4
+ "description": "AI-first CRM CLI manage leads and API keys from the terminal",
5
5
  "main": "index.js",
6
6
  "bin": {
7
7
  "saac_crm": "./index.js"