@letterstory/cli 0.43.0 → 0.45.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,8 +386,19 @@ 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
+
390
+ # One-click onboarding of a won client (key needs crm:write + fleet:onboard + org:create)
391
+ letterstory admin crm onboard <account-id> --domain acme.com \
392
+ --site "Where fintech compliance is heading" --site "" \
393
+ --slack jane@acme.com --invite jane@acme.com:admin --invite bob@acme.com
394
+ letterstory admin crm onboarding <account-id> # step-by-step progress (sites refreshed)
395
+ letterstory admin crm retry-onboarding <onboarding-id> # re-run only the failed steps
389
396
  ```
390
397
 
398
+ `onboard` runs org → link → phantom sites → Slack channel → checklist → app invites →
399
+ 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.
401
+
391
402
  Every trigger — Cal.com bookings, Circleback recordings, Letterhome dinner RSVPs, RB2B
392
403
  visits, and what an agent records here — goes through one intake (`ingestSignal`,
393
404
  src/lib/crm/signals-intake.ts), so an agent's signal dedupes against a webhook's exactly
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.43.0";
55
+ export const VERSION = "0.45.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.
@@ -61,6 +61,7 @@ const BOOLEAN_FLAGS = new Set([
61
61
  "yes",
62
62
  "confirm-missing-target",
63
63
  "no-wait",
64
+ "no-checklist",
64
65
  "accept",
65
66
  "help",
66
67
  "version",
@@ -563,13 +564,28 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
563
564
  | accounts [--stage lead] [--q acme] | account <id> | score <account-id>
564
565
  | slack-channels [--all] | link-slack <account-id> <channel-id|none>
565
566
  | white-glove [--suggested] | set-white-glove <account-id> on|off [--note "…"]
567
+ | churn [--months 12] | record-churn <account-id> <reason> [--note "…"] [--at ISO]
568
+ [--mrr-cents N] | undo-churn <account-id>
569
+ | onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
570
+ [--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
571
+ | onboarding <account-id> | retry-onboarding <onboarding-id>
566
572
  The staff CRM's pipeline: the inbound signal
567
573
  feed, recording a signal with no webhook
568
574
  (Bookface, a tweet like), qualifying or
569
575
  dismissing one, and the explained score.
570
576
  White glove is a person's decision: the
571
577
  desk only suggests ($1K+/mo post-sales).
572
- Needs crm:read (reads) / crm:write (writes).
578
+ Churn: 30/90-day logo + revenue rates, who
579
+ churned and why; Stripe cancellations are
580
+ only suggestions until a person records one.
581
+ Onboard runs org → link → phantom sites →
582
+ Slack channel → checklist → app invites →
583
+ stage in one call (--site "" = infer the
584
+ beat from their homepage); retry re-runs
585
+ only what failed.
586
+ Needs crm:read (reads) / crm:write (writes);
587
+ onboard + retry also need fleet:onboard and
588
+ org:create.
573
589
  admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
574
590
  the pipeline as deployed, levers, measures,
575
591
  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 … (crm:read / crm:write)
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)
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,6 +425,57 @@ 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
+ function onboardingLines(o) {
429
+ if (!o) return "(never onboarded from the CRM)";
430
+ const mark = { done: "✓", skipped: "–", failed: "✗", running: "…", pending: "·" };
431
+ return [
432
+ `${o.id} started ${o.startedAt.slice(0, 16).replace("T", " ")}${o.finishedAt ? " finished" : " in progress"}`,
433
+ ...o.steps.map(
434
+ (s) =>
435
+ ` ${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}` : ""}`
436
+ ),
437
+ ].join("\n");
438
+ }
439
+
440
+ /**
441
+ * `admin crm onboard` body from flags — the same CrmOnboardBodySchema the desk sends.
442
+ * --site "<direction>" per site (repeatable; "" = infer from the homepage) and/or
443
+ * --sites N (pads with inferred sites); --slack a@x,b@x (bare --slack = the channel, no
444
+ * invites yet); --invite email[:admin|editor] (repeatable); --no-checklist.
445
+ */
446
+ function onboardBody(flags) {
447
+ const domain = flagStr(flags.domain);
448
+ if (!domain) {
449
+ throw new CliError(
450
+ 'Usage: admin crm onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N] [--slack a@x,b@x] [--invite a@x[:admin]]… [--no-checklist]'
451
+ );
452
+ }
453
+ const sites = flagList(flags.site).map((direction) => ({ direction: direction.trim() || null }));
454
+ const total = flagNum(flags.sites);
455
+ while (total !== undefined && sites.length < total) sites.push({ direction: null });
456
+ const slackEmails = flagList(flags.slack)
457
+ .flatMap((v) => v.split(","))
458
+ .map((e) => e.trim())
459
+ .filter(Boolean);
460
+ const slack = flags.slack === undefined ? null : { emails: slackEmails };
461
+ const invites = flagList(flags.invite)
462
+ .flatMap((v) => v.split(","))
463
+ .map((v) => v.trim())
464
+ .filter(Boolean)
465
+ .map((v) => {
466
+ const [email, role] = v.split(":");
467
+ return { email, role: role === "admin" ? "admin" : "editor" };
468
+ });
469
+ return compact({
470
+ domain,
471
+ name: flagStr(flags.name),
472
+ sites,
473
+ slack,
474
+ invites,
475
+ checklist: flagBool(flags["no-checklist"]) ? false : undefined,
476
+ });
477
+ }
478
+
428
479
  /**
429
480
  * `admin crm …` — the headless half of the staff CRM, so a background agent can watch the
430
481
  * pipeline and record the signals that have no webhook (Bookface, tweet likes, DMs).
@@ -567,9 +618,85 @@ async function cmdAdminCrm(ctx) {
567
618
  );
568
619
  return out(r, () => (action === "on" ? "moved to white glove" : "left white glove"));
569
620
  }
621
+ case "churn": {
622
+ // admin crm churn [--months 12] — the 30/90-day rates, who churned and why, likely churns.
623
+ const params = new URLSearchParams(compact({ months: flagStr(flags.months) }));
624
+ const pct = (x) => (x == null ? "—" : `${(x * 100).toFixed(1)}%`);
625
+ const usd = (cents) => `$${Math.round(cents / 100).toLocaleString("en-US")}`;
626
+ const win = (label, w) =>
627
+ `${label}: ${w.churned}/${w.customersAtStart} customers (${pct(w.rate)}) · ${usd(w.mrrLostCents)}/mo lost (${pct(w.revenueRate)} of MRR)`;
628
+ return out(await staffRequest(ctx, "GET", `${CRM}/churn?${params}`), (r) =>
629
+ [
630
+ win("30d", r.window.days30),
631
+ win("90d", r.window.days90),
632
+ "",
633
+ "Churned:",
634
+ ...(r.churned.length
635
+ ? r.churned.map(
636
+ (c) =>
637
+ ` ${c.churnedAt.slice(0, 10)}${c.churnedAtSource === "undated" ? "?" : ""} ${c.account.name} ${c.reason ?? "unrecorded"} ${usd(c.mrrLostCents)}/mo${c.note ? ` — ${c.note}` : ""}`
638
+ )
639
+ : [" (none)"]),
640
+ "",
641
+ "Likely churned (not recorded):",
642
+ ...(r.likely.length
643
+ ? r.likely.map((l) => ` ${accountLine(l.account)}\n ${l.signal}`)
644
+ : [" (none)"]),
645
+ ].join("\n")
646
+ );
647
+ }
648
+ case "record-churn": {
649
+ // admin crm record-churn <account-id> <reason> [--note "…"] [--at ISO] [--mrr-cents N]
650
+ if (!id || !action) {
651
+ throw new CliError(
652
+ 'Usage: admin crm record-churn <account-id> <price|no_results|competitor|in_house|shut_down|budget|bad_fit|service|other> [--note "…"] [--at 2026-09-22T00:00:00Z] [--mrr-cents 50000]'
653
+ );
654
+ }
655
+ const r = await staffPost(
656
+ ctx,
657
+ `${CRM}/accounts/${id}/churn`,
658
+ compact({
659
+ reason: action,
660
+ note: flagStr(flags.note),
661
+ churned_at: flagStr(flags.at),
662
+ mrr_cents: flagNum(flags["mrr-cents"]),
663
+ })
664
+ );
665
+ return out(
666
+ r,
667
+ (x) =>
668
+ `${x.change === "updated" ? "churn updated" : "churn recorded"}: ${accountLine(x.account)} · ${x.account.churnMrrCents == null ? "no MRR on file" : `$${Math.round(x.account.churnMrrCents / 100)}/mo lost`}`
669
+ );
670
+ }
671
+ case "undo-churn": {
672
+ // admin crm undo-churn <account-id> — clear the churn and move back to active.
673
+ if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
674
+ const r = await staffRequest(ctx, "DELETE", `${CRM}/accounts/${id}/churn`);
675
+ return out(r, (x) => `churn undone: ${accountLine(x.account)}`);
676
+ }
677
+ case "onboard": {
678
+ // admin crm onboard <account-id> --domain acme.com … — one-click onboarding of a won
679
+ // client. The key needs crm:write + fleet:onboard + org:create.
680
+ if (!id) throw new CliError("Usage: admin crm onboard <account-id> --domain acme.com … (see --help)");
681
+ const r = await staffPost(ctx, `${CRM}/accounts/${id}/onboard`, onboardBody(flags));
682
+ return out(r, (x) => onboardingLines(x.onboarding));
683
+ }
684
+ case "onboarding": {
685
+ // admin crm onboarding <account-id> — the latest onboarding, sites refreshed.
686
+ if (!id) throw new CliError("Usage: admin crm onboarding <account-id>");
687
+ return out(await staffRequest(ctx, "GET", `${CRM}/accounts/${id}/onboard`), (x) =>
688
+ onboardingLines(x.onboarding)
689
+ );
690
+ }
691
+ case "retry-onboarding": {
692
+ // admin crm retry-onboarding <onboarding-id> — re-run the failed/pending steps.
693
+ if (!id) throw new CliError("Usage: admin crm retry-onboarding <onboarding-id>");
694
+ const r = await staffPost(ctx, `${CRM}/onboardings/${id}/retry`, {});
695
+ return out(r, (x) => onboardingLines(x.onboarding));
696
+ }
570
697
  default:
571
698
  throw new CliError(
572
- "Usage: admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove … (see --help)"
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)"
573
700
  );
574
701
  }
575
702
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.43.0",
3
+ "version": "0.45.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",