@letterstory/cli 0.45.0 → 0.46.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.
package/README.md CHANGED
@@ -386,6 +386,8 @@ letterstory admin crm accounts --stage demo_scheduled # with likelihood
386
386
  letterstory admin crm score <account-id> # recompute; prints the factors
387
387
  letterstory admin crm slack-channels [--all] # Watchdog channels + the account each is linked to
388
388
  letterstory admin crm link-slack <account-id> C0123ABC # the CRM's watch-only Slack link (`none` unlinks)
389
+ letterstory admin crm import leads.csv --dry-run # bulk-import leads: what would be created/matched
390
+ letterstory admin crm import leads.csv --tag "YC W26" # …and for real (dedupes by domain, then company name)
389
391
 
390
392
  # One-click onboarding of a won client (key needs crm:write + fleet:onboard + org:create)
391
393
  letterstory admin crm onboard <account-id> --domain acme.com \
@@ -393,11 +395,16 @@ letterstory admin crm onboard <account-id> --domain acme.com \
393
395
  --slack jane@acme.com --invite jane@acme.com:admin --invite bob@acme.com
394
396
  letterstory admin crm onboarding <account-id> # step-by-step progress (sites refreshed)
395
397
  letterstory admin crm retry-onboarding <onboarding-id> # re-run only the failed steps
398
+ letterstory admin crm onboard-plan <account-id> # what the calls say: sites, Slack/app people, paid?
399
+ letterstory admin crm payment-link <account-id> # Stripe Payment Link at the deal amount (billing:write)
396
400
  ```
397
401
 
398
402
  `onboard` runs org → link → phantom sites → Slack channel → checklist → app invites →
399
403
  stage in one call. Each site is a real (not test) fleet build; `--site ""` lets the engine
400
- infer its beat from the client's homepage.
404
+ infer its beat from the client's homepage. `onboard-plan` reads the account's recorded calls
405
+ (newest first, cached per set of calls; `--refresh` re-reads) and says how many sites, what each
406
+ is about, who to invite, and whether they've paid. `payment-link` stamps the account's org on
407
+ the Stripe subscription, so paying through it binds and marks the account paid by itself.
401
408
 
402
409
  Every trigger — Cal.com bookings, Circleback recordings, Letterhome dinner RSVPs, RB2B
403
410
  visits, and what an agent records here — goes through one intake (`ingestSignal`,
package/lib/cli.mjs CHANGED
@@ -52,7 +52,7 @@ import {
52
52
  } from "./commands.mjs";
53
53
 
54
54
  // Keep in sync with cli/package.json.
55
- export const VERSION = "0.45.0";
55
+ export const VERSION = "0.46.0";
56
56
 
57
57
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
58
58
  // can't accidentally swallow the id as --json's value.
@@ -62,6 +62,7 @@ const BOOLEAN_FLAGS = new Set([
62
62
  "confirm-missing-target",
63
63
  "no-wait",
64
64
  "no-checklist",
65
+ "refresh",
65
66
  "accept",
66
67
  "help",
67
68
  "version",
@@ -557,7 +558,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
557
558
  session (a trial per arm), linked to its
558
559
  ledger entry. Written by \`npm run lab --
559
560
  publish\` in the kernels repo.
560
- admin crm signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
561
+ admin crm import <file.csv> [--source <name>] [--tag "…"] [--dry-run]
562
+ | signals [--status new] [--intent qualified] [--source rb2b] [--account <id>]
561
563
  | record --title "…" [--source bookface] [--kind other] [--intent qualified]
562
564
  [--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
563
565
  | signal <id> qualify|dismiss|link [--account <id>]
@@ -569,7 +571,10 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
569
571
  | onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
570
572
  [--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
571
573
  | onboarding <account-id> | retry-onboarding <onboarding-id>
572
- The staff CRM's pipeline: the inbound signal
574
+ | onboard-plan <account-id> [--refresh]
575
+ | payment-link <account-id> [--amount-cents N] [--description "…"]
576
+ The staff CRM's pipeline: bulk-importing a
577
+ CSV of leads, the inbound signal
573
578
  feed, recording a signal with no webhook
574
579
  (Bookface, a tweet like), qualifying or
575
580
  dismissing one, and the explained score.
@@ -585,7 +590,9 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
585
590
  only what failed.
586
591
  Needs crm:read (reads) / crm:write (writes);
587
592
  onboard + retry also need fleet:onboard and
588
- org:create.
593
+ org:create. onboard-plan reads the calls
594
+ (sites, who to invite, paid or not);
595
+ payment-link needs billing:write.
589
596
  admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
590
597
  the pipeline as deployed, levers, measures,
591
598
  goals, related history.
@@ -7,7 +7,7 @@
7
7
  // admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
8
8
  // admin tuning-context [--q "<question>"] (tuning:experiment)
9
9
  // admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
10
- // admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding … (crm:read / crm:write)
10
+ // admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
11
11
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
12
12
  // [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
13
13
  // [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
@@ -425,11 +425,135 @@ function accountLine(a) {
425
425
  return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
426
426
  }
427
427
 
428
+ /* ------------------------------------------------------ CRM lead import (CSV) */
429
+
430
+ /** RFC 4180-ish: quoted fields, doubled quotes, CRLF/LF, a BOM. Returns rows of cells. */
431
+ export function parseCsvText(text) {
432
+ const src = text.replace(/^/, "");
433
+ const rows = [];
434
+ let row = [];
435
+ let cell = "";
436
+ let quoted = false;
437
+ for (let i = 0; i < src.length; i++) {
438
+ const c = src[i];
439
+ if (quoted) {
440
+ if (c === '"') {
441
+ if (src[i + 1] === '"') {
442
+ cell += '"';
443
+ i++;
444
+ } else quoted = false;
445
+ } else cell += c;
446
+ } else if (c === '"') quoted = true;
447
+ else if (c === ",") {
448
+ row.push(cell);
449
+ cell = "";
450
+ } else if (c === "\n" || c === "\r") {
451
+ if (c === "\r" && src[i + 1] === "\n") i++;
452
+ row.push(cell);
453
+ rows.push(row);
454
+ row = [];
455
+ cell = "";
456
+ } else cell += c;
457
+ }
458
+ if (cell !== "" || row.length > 0) {
459
+ row.push(cell);
460
+ rows.push(row);
461
+ }
462
+ return rows.filter((r) => r.some((c) => c.trim() !== ""));
463
+ }
464
+
465
+ /** Spreadsheet header → CrmImportRow field (headers compared lower-cased, letters and digits only). */
466
+ const CRM_IMPORT_HEADERS = {
467
+ company: ["company", "companyname", "organization", "organisation", "account", "accountname"],
468
+ domain: ["domain", "companydomain"],
469
+ website: ["website", "url", "companywebsite", "companyurl", "site"],
470
+ name: ["name", "fullname", "contact", "contactname", "person"],
471
+ firstName: ["firstname", "first", "givenname"],
472
+ lastName: ["lastname", "last", "surname", "familyname"],
473
+ email: ["email", "emailaddress", "workemail", "businessemail"],
474
+ title: ["title", "jobtitle", "position", "role"],
475
+ linkedin: ["linkedin", "linkedinurl", "linkedinprofile"],
476
+ phone: ["phone", "phonenumber", "mobile"],
477
+ notes: ["notes", "note", "comments", "description"],
478
+ yc: ["yc", "isyc", "ycombinator"],
479
+ batch: ["batch", "ycbatch"],
480
+ };
481
+
482
+ /** A CSV's text → the CrmImportRow[] the import route takes (unknown columns are ignored). */
483
+ export function csvToImportRows(text) {
484
+ const [header, ...body] = parseCsvText(text);
485
+ if (!header) return { rows: [], mapped: [] };
486
+ const key = (h) => h.toLowerCase().replace(/[^a-z0-9]/g, "");
487
+ const columns = header.map((h) => {
488
+ const k = key(h);
489
+ return Object.keys(CRM_IMPORT_HEADERS).find((f) => CRM_IMPORT_HEADERS[f].includes(k)) ?? null;
490
+ });
491
+ const rows = body.map((cells) => {
492
+ const row = {};
493
+ columns.forEach((field, i) => {
494
+ const v = (cells[i] ?? "").trim();
495
+ if (field && v && row[field] === undefined) row[field] = v;
496
+ });
497
+ return row;
498
+ });
499
+ return { rows, mapped: [...new Set(columns.filter(Boolean))] };
500
+ }
501
+
502
+ /** The route's cap (CRM_IMPORT_MAX_ROWS in src/types/crm.ts). */
503
+ const CRM_IMPORT_CHUNK = 2000;
504
+
505
+ /**
506
+ * `admin crm import <file.csv> [--source name] [--tag "…"] [--dry-run]` — the same importLeads
507
+ * the CRM's upload calls (POST /api/superadmin/crm/import), 2,000 rows per request.
508
+ */
509
+ async function cmdAdminCrmImport(ctx, path) {
510
+ const { flags, io } = ctx;
511
+ if (!path) throw new CliError('Usage: admin crm import <file.csv> [--source name] [--tag "…"] [--dry-run]');
512
+ let text;
513
+ try {
514
+ text = readFileSync(path, "utf8");
515
+ } catch (err) {
516
+ throw new CliError(`Could not read ${path}: ${err.message}`);
517
+ }
518
+ const { rows, mapped } = csvToImportRows(text);
519
+ if (rows.length === 0) throw new CliError(`${path} has no data rows`);
520
+ if (!["company", "domain", "website", "email"].some((f) => mapped.includes(f))) {
521
+ throw new CliError("No company, domain, website or email column found in the header row");
522
+ }
523
+ const source = flagStr(flags.source) ?? path.split(/[\\/]/).pop();
524
+ const dryRun = flagBool(flags["dry-run"]);
525
+ const total = { created: 0, matched: 0, contactsAdded: 0, skipped: 0, rows: [] };
526
+ for (let offset = 0; offset < rows.length; offset += CRM_IMPORT_CHUNK) {
527
+ const r = await staffPost(
528
+ ctx,
529
+ `${CRM}/import`,
530
+ compact({ rows: rows.slice(offset, offset + CRM_IMPORT_CHUNK), source, dryRun, tag: flagStr(flags.tag) })
531
+ );
532
+ total.created += r.created;
533
+ total.matched += r.matched;
534
+ total.contactsAdded += r.contactsAdded;
535
+ total.skipped += r.skipped;
536
+ total.rows.push(...r.rows.map((x) => ({ ...x, index: x.index + offset })));
537
+ }
538
+ if (flagBool(flags.json)) return printResult(io, flags, total);
539
+ const errors = total.rows.filter((x) => x.status === "error").length;
540
+ io.log(
541
+ `${dryRun ? "Dry run — would import" : "Imported"} ${rows.length} rows from ${source} (columns: ${mapped.join(", ")}):\n` +
542
+ ` ${total.created} accounts ${dryRun ? "to create" : "created"}, ${total.matched} matched, ` +
543
+ `${total.contactsAdded} contacts ${dryRun ? "to add" : "added"}, ${total.skipped} skipped${errors ? `, ${errors} errors` : ""}`
544
+ );
545
+ for (const x of total.rows.filter((y) => y.status === "skipped" || y.status === "error")) {
546
+ // +2: the header is line 1 and the index is 0-based.
547
+ io.log(` line ${x.index + 2}: ${x.status} — ${x.reason ?? ""}`);
548
+ }
549
+ }
550
+
428
551
  function onboardingLines(o) {
429
552
  if (!o) return "(never onboarded from the CRM)";
430
553
  const mark = { done: "✓", skipped: "–", failed: "✗", running: "…", pending: "·" };
431
554
  return [
432
555
  `${o.id} started ${o.startedAt.slice(0, 16).replace("T", " ")}${o.finishedAt ? " finished" : " in progress"}`,
556
+ ...(o.billing ? [` ${billingLine(o.billing)}`] : []),
433
557
  ...o.steps.map(
434
558
  (s) =>
435
559
  ` ${mark[s.status] ?? "?"} ${s.key.padEnd(9)} ${s.status}${s.progress ? ` (${s.progress.done}/${s.progress.total})` : ""}${s.detail ? ` ${s.detail}` : ""}${s.error ? `\n error: ${s.error}` : ""}`
@@ -437,6 +561,32 @@ function onboardingLines(o) {
437
561
  ].join("\n");
438
562
  }
439
563
 
564
+ function dollars(cents) {
565
+ return `$${(cents / 100).toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
566
+ }
567
+
568
+ function billingLine(b) {
569
+ const paid = b.paid
570
+ ? `PAID${b.mrrCents ? ` (${dollars(b.mrrCents)}/mo)` : ""}${b.paidAt ? ` since ${b.paidAt.slice(0, 10)}` : ""}`
571
+ : "NOT PAID";
572
+ return `billing: ${paid}${b.paymentLinkUrl ? ` link: ${b.paymentLinkUrl}` : ""}`;
573
+ }
574
+
575
+ function planLines(p) {
576
+ return [
577
+ `domain: ${p.domain ?? "(unknown)"}`,
578
+ `sites: ${p.sites.length}`,
579
+ ...p.sites.map(
580
+ (s, i) => ` ${i + 1}. ${s.direction || "(infer from their homepage)"}${s.why ? ` — ${s.why}` : ""}`
581
+ ),
582
+ `slack: ${p.slackEmails.join(", ") || "(nobody found)"}`,
583
+ `invites: ${p.invites.map((i) => `${i.email}:${i.role}`).join(", ") || "(none)"}`,
584
+ ...(p.notes ? [`notes: ${p.notes}`] : []),
585
+ billingLine(p.billing),
586
+ `read: ${p.sources.map((s) => `${s.title ?? s.transcriptId} (${s.occurredAt.slice(0, 10)})`).join("; ") || "(no calls on file)"}`,
587
+ ].join("\n");
588
+ }
589
+
440
590
  /**
441
591
  * `admin crm onboard` body from flags — the same CrmOnboardBodySchema the desk sends.
442
592
  * --site "<direction>" per site (repeatable; "" = infer from the homepage) and/or
@@ -488,6 +638,8 @@ async function cmdAdminCrm(ctx) {
488
638
  const json = flagBool(flags.json);
489
639
  const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
490
640
  switch (sub) {
641
+ case "import":
642
+ return cmdAdminCrmImport(ctx, id);
491
643
  case "signals": {
492
644
  const params = new URLSearchParams(
493
645
  compact({
@@ -688,6 +840,32 @@ async function cmdAdminCrm(ctx) {
688
840
  onboardingLines(x.onboarding)
689
841
  );
690
842
  }
843
+ case "onboard-plan": {
844
+ // admin crm onboard-plan <account-id> [--refresh] — what the calls say onboarding
845
+ // should set up (sites, Slack/app people, notes) + paid or not. --refresh re-reads.
846
+ if (!id) throw new CliError("Usage: admin crm onboard-plan <account-id> [--refresh]");
847
+ const q = flagBool(flags.refresh) ? "?refresh=1" : "";
848
+ return out(await staffRequest(ctx, "GET", `${CRM}/accounts/${id}/onboard/plan${q}`), (x) =>
849
+ planLines(x.plan)
850
+ );
851
+ }
852
+ case "payment-link": {
853
+ // admin crm payment-link <account-id> [--amount-cents N] [--description "…"] — a Stripe
854
+ // Payment Link for the monthly subscription (default: the deal amount). The key needs
855
+ // crm:write + billing:write.
856
+ if (!id)
857
+ throw new CliError('Usage: admin crm payment-link <account-id> [--amount-cents N] [--description "…"]');
858
+ const r = await staffPost(
859
+ ctx,
860
+ `${CRM}/accounts/${id}/payment-link`,
861
+ compact({ amountCents: flagNum(flags["amount-cents"]), description: flagStr(flags.description) })
862
+ );
863
+ return out(
864
+ r,
865
+ (x) =>
866
+ `${x.url} (${dollars(x.amountCents)}/month${x.orgId ? "" : " — no org yet: bind in Fleet → Billing"})`
867
+ );
868
+ }
691
869
  case "retry-onboarding": {
692
870
  // admin crm retry-onboarding <onboarding-id> — re-run the failed/pending steps.
693
871
  if (!id) throw new CliError("Usage: admin crm retry-onboarding <onboarding-id>");
@@ -696,7 +874,7 @@ async function cmdAdminCrm(ctx) {
696
874
  }
697
875
  default:
698
876
  throw new CliError(
699
- "Usage: admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding … (see --help)"
877
+ "Usage: admin crm import|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
700
878
  );
701
879
  }
702
880
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",