@letterstory/cli 0.115.0 → 0.117.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/lib/cli.mjs CHANGED
@@ -60,7 +60,7 @@ import {
60
60
  } from "./commands.mjs";
61
61
 
62
62
  // Keep in sync with cli/package.json.
63
- export const VERSION = "0.115.0";
63
+ export const VERSION = "0.117.0";
64
64
 
65
65
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
66
66
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -98,6 +98,8 @@ export const BOOLEAN_FLAGS = new Set([
98
98
  "verbose",
99
99
  "no-color",
100
100
  "dry-run",
101
+ // admin crm record-outcome --draft
102
+ "draft",
101
103
  "prune",
102
104
  // org git sync --force
103
105
  "force",
@@ -786,7 +788,13 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
786
788
  | slack-channels [--all] | link-slack <account-id> <channel-id|none>
787
789
  | white-glove [--suggested] | set-white-glove <account-id> on|off [--note "…"]
788
790
  | churn [--months 12] | record-churn <account-id> <reason> [--note "…"] [--at ISO]
789
- [--mrr-cents N] | undo-churn <account-id> | draft-churn-reason <account-id>
791
+ [--mrr-cents N] [--cause voluntary|involuntary|unknown] [--notice-at ISO] | undo-churn <account-id> | draft-churn-reason <account-id>
792
+ | outcomes [--account <id>] [--kind churn|contraction|expansion|reactivation|save]
793
+ [--status draft|confirmed|discarded] [--since ISO] [--until ISO] [--limit N]
794
+ | record-outcome <account-id> <kind> [--reason code] [--also a,b] [--cause …] [--notice-at ISO]
795
+ [--at ISO] [--mrr-before N] [--mrr-after N] [--note "…"] [--save-offer …]
796
+ [--save-result …] [--draft]
797
+ | renewals [--days 90]
790
798
  | onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
791
799
  [--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
792
800
  | onboarding <account-id> | retry-onboarding <onboarding-id>
@@ -800,11 +808,18 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
800
808
  White glove is a person's decision: the
801
809
  desk only suggests ($1K+/mo post-sales).
802
810
  Churn: 30/90-day logo + revenue rates, who
803
- churned and why; Stripe cancellations are
804
- only suggestions until a person records one.
811
+ churned and why, split voluntary /
812
+ involuntary, and payments failing now;
813
+ Stripe cancellations are only suggestions
814
+ until a person records one.
805
815
  draft-churn-reason drafts a reason from
806
816
  what the CRM kept; a person confirms it in
807
817
  the proposals queue.
818
+ Outcomes keep every churn, contraction,
819
+ expansion, reactivation and save with its
820
+ reason; a key records drafts a person
821
+ confirms. Renewals: contracts renewing
822
+ within N days, and Stripe cancels scheduled.
808
823
  Onboard runs org → link → phantom sites →
809
824
  Slack channel → checklist → app invites →
810
825
  stage in one call (--site "" = infer the
@@ -14,7 +14,7 @@
14
14
  // The old names — evals, eval-add, eval-set, proposals, propose-eval, capture-eval, reject-eval — still work.)
15
15
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
16
16
  // admin fleet-experiments list|articles|clients|mark|unmark|new|assign|assign-eligible|assign-orgs|spin-up|apply|start|stop|decide … (fleet:read / fleet:experiment)
17
- // admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|revenue|record-churn|draft-churn-reason|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
17
+ // admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|revenue|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
18
18
  // admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
19
19
  // admin brand-detection <org-id> (fleet:read)
20
20
  // admin brand-detection <org-id> [--term t]... [--exact t]... [--exclude p]... [--note "…"] | --clear [--note "…"] (fleet:instrument)
@@ -2562,6 +2562,16 @@ function signalLine(s) {
2562
2562
  return `${s.id} [${s.status} · ${s.intent}] ${s.occurredAt.slice(0, 10)} ${s.source}/${s.kind} ${s.title}${who ? `\n ${who}` : ""}`;
2563
2563
  }
2564
2564
 
2565
+ /** One outcome on a line: when, kind, status, reason, MRR move, id. */
2566
+ function outcomeLine(o) {
2567
+ const usd = (c) => (c == null ? "?" : `$${Math.round(c / 100).toLocaleString("en-US")}`);
2568
+ const mrr =
2569
+ o.mrrBeforeCents != null || o.mrrAfterCents != null
2570
+ ? ` ${usd(o.mrrBeforeCents)} → ${usd(o.mrrAfterCents)}/mo`
2571
+ : "";
2572
+ return `${o.effectiveAt.slice(0, 10)} ${o.kind} ${o.status} ${o.primaryReason ?? "no reason"}${mrr}${o.note ? ` — ${o.note}` : ""} ${o.id}`;
2573
+ }
2574
+
2565
2575
  function accountLine(a) {
2566
2576
  const score = a.likelihood == null ? "—" : a.likelihood.toFixed(2);
2567
2577
  return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
@@ -3322,12 +3332,23 @@ async function cmdAdminCrm(ctx) {
3322
3332
  return out(r, () => (action === "on" ? "moved to white glove" : "left white glove"));
3323
3333
  }
3324
3334
  case "churn": {
3325
- // admin crm churn [--months 12] — the 30/90-day rates, who churned and why, likely churns.
3335
+ // admin crm churn [--months 12] — the 30/90-day rates split voluntary / involuntary,
3336
+ // who churned and why, likely churns, and payments failing now.
3326
3337
  const params = new URLSearchParams(compact({ months: flagStr(flags.months) }));
3327
3338
  const pct = (x) => (x == null ? "—" : `${(x * 100).toFixed(1)}%`);
3328
3339
  const usd = (cents) => `$${Math.round(cents / 100).toLocaleString("en-US")}`;
3340
+ const split = (b) =>
3341
+ b
3342
+ ? ` voluntary ${b.voluntary.churned} · ${usd(b.voluntary.mrrLostCents)}/mo involuntary ${b.involuntary.churned} · ${usd(b.involuntary.mrrLostCents)}/mo unknown ${b.unknown.churned} · ${usd(b.unknown.mrrLostCents)}/mo`
3343
+ : null;
3329
3344
  const win = (label, w) =>
3330
- `${label}: ${w.churned}/${w.customersAtStart} customers (${pct(w.rate)}) · ${usd(w.mrrLostCents)}/mo lost (${pct(w.revenueRate)} of MRR)`;
3345
+ [
3346
+ `${label}: ${w.churned}/${w.customersAtStart} customers (${pct(w.rate)}) · ${usd(w.mrrLostCents)}/mo lost (${pct(w.revenueRate)} of MRR)`,
3347
+ split(w.byCause),
3348
+ ]
3349
+ .filter(Boolean)
3350
+ .join("\n");
3351
+ const cause = (x) => (x.cause ? ` [${x.cause}${x.causeSignal ? `: ${x.causeSignal}` : ""}]` : "");
3331
3352
  return out(await staffRequest(ctx, "GET", `${CRM}/churn?${params}`), (r) =>
3332
3353
  [
3333
3354
  win("30d", r.window.days30),
@@ -3337,14 +3358,24 @@ async function cmdAdminCrm(ctx) {
3337
3358
  ...(r.churned.length
3338
3359
  ? r.churned.map(
3339
3360
  (c) =>
3340
- ` ${c.churnedAt.slice(0, 10)}${c.churnedAtSource === "undated" ? "?" : ""} ${c.account.name} ${c.reason ?? "unrecorded"} ${usd(c.mrrLostCents)}/mo${c.note ? ` — ${c.note}` : ""}`
3361
+ ` ${c.churnedAt.slice(0, 10)}${c.churnedAtSource === "undated" ? "?" : ""} ${c.account.name} ${c.reason ?? "unrecorded"} ${usd(c.mrrLostCents)}/mo${cause(c)}${c.note ? ` — ${c.note}` : ""}`
3341
3362
  )
3342
3363
  : [" (none)"]),
3343
3364
  "",
3344
3365
  "Likely churned (not recorded):",
3345
3366
  ...(r.likely.length
3346
- ? r.likely.map((l) => ` ${accountLine(l.account)}\n ${l.signal}`)
3367
+ ? r.likely.map((l) => ` ${accountLine(l.account)}${cause(l)}\n ${l.signal}`)
3347
3368
  : [" (none)"]),
3369
+ "",
3370
+ "Payments failing now:",
3371
+ ...(r.failing == null
3372
+ ? [" (unavailable: the billing read failed, so this is not an all-clear)"]
3373
+ : r.failing.length
3374
+ ? r.failing.map(
3375
+ (f) =>
3376
+ ` ${f.name} ${f.amountCents == null ? "amount unknown" : usd(f.amountCents)}${f.since ? ` since ${f.since.slice(0, 10)}` : ""}${f.attempts != null ? ` ${f.attempts} attempt${f.attempts === 1 ? "" : "s"}` : ""} → ${f.action ?? (f.nudgeInHours != null ? `wait ${f.nudgeInHours}h (Stripe retrying)` : "none")} (${f.accountId ?? "no account"})\n ${f.reason}`
3377
+ )
3378
+ : [" (none)"]),
3348
3379
  ].join("\n")
3349
3380
  );
3350
3381
  }
@@ -3381,6 +3412,8 @@ async function cmdAdminCrm(ctx) {
3381
3412
  note: flagStr(flags.note),
3382
3413
  churned_at: flagStr(flags.at),
3383
3414
  mrr_cents: flagNum(flags["mrr-cents"]),
3415
+ cause: flagStr(flags.cause),
3416
+ notice_at: flagStr(flags["notice-at"]),
3384
3417
  })
3385
3418
  );
3386
3419
  return out(
@@ -3406,6 +3439,69 @@ async function cmdAdminCrm(ctx) {
3406
3439
  : `${x.status.replaceAll("_", " ")}${x.detail ? ` (${x.detail.replaceAll("_", " ")})` : ""}`
3407
3440
  );
3408
3441
  }
3442
+ case "outcomes": {
3443
+ // admin crm outcomes [--account id] [--kind …] [--status …] [--since ISO] [--until ISO] [--limit N]
3444
+ const params = new URLSearchParams(
3445
+ compact({
3446
+ account_id: flagStr(flags.account),
3447
+ kind: flagStr(flags.kind),
3448
+ status: flagStr(flags.status),
3449
+ since: flagStr(flags.since),
3450
+ until: flagStr(flags.until),
3451
+ limit: flagStr(flags.limit),
3452
+ })
3453
+ );
3454
+ return out(await staffRequest(ctx, "GET", `${CRM}/outcomes?${params}`), (r) =>
3455
+ r.outcomes.length ? r.outcomes.map(outcomeLine).join("\n") : "(no outcomes)"
3456
+ );
3457
+ }
3458
+ case "record-outcome": {
3459
+ // admin crm record-outcome <account-id> <kind> [--reason code] [--also a,b] … [--draft]
3460
+ if (!id || !action) {
3461
+ throw new CliError(
3462
+ 'Usage: admin crm record-outcome <account-id> <churn|contraction|expansion|reactivation|save> [--reason code] [--also a,b] [--cause voluntary|involuntary|unknown] [--notice-at ISO] [--at ISO] [--mrr-before N] [--mrr-after N] [--note "…"] [--save-offer discount|pause|downgrade|fix|other] [--save-result kept|lost|pending] [--draft]'
3463
+ );
3464
+ }
3465
+ const also = flagStr(flags.also);
3466
+ const r = await staffPost(
3467
+ ctx,
3468
+ `${CRM}/outcomes`,
3469
+ compact({
3470
+ account_id: id,
3471
+ kind: action,
3472
+ status: flagBool(flags.draft) ? "draft" : undefined,
3473
+ primary_reason: flagStr(flags.reason),
3474
+ contributing_reasons: also ? also.split(",").map((x) => x.trim()) : undefined,
3475
+ cause: flagStr(flags.cause),
3476
+ notice_at: flagStr(flags["notice-at"]),
3477
+ effective_at: flagStr(flags.at),
3478
+ mrr_before_cents: flagNum(flags["mrr-before"]),
3479
+ mrr_after_cents: flagNum(flags["mrr-after"]),
3480
+ note: flagStr(flags.note),
3481
+ save_offer: flagStr(flags["save-offer"]),
3482
+ save_result: flagStr(flags["save-result"]),
3483
+ })
3484
+ );
3485
+ return out(
3486
+ r,
3487
+ (x) =>
3488
+ `${x.outcome.status === "draft" ? "drafted (a person confirms it)" : "recorded"}: ${outcomeLine(x.outcome)}`
3489
+ );
3490
+ }
3491
+ case "renewals": {
3492
+ // admin crm renewals [--days 90] — contracts renewing soon, and Stripe cancels scheduled.
3493
+ const params = new URLSearchParams(compact({ days: flagStr(flags.days) }));
3494
+ return out(await staffRequest(ctx, "GET", `${CRM}/renewals?${params}`), (r) =>
3495
+ r.renewals.length
3496
+ ? r.renewals
3497
+ .map(
3498
+ (x) =>
3499
+ `${x.daysLeft == null ? " ?" : String(x.daysLeft).padStart(3)}d ${x.name} ${x.renewsAt ? `renews ${x.renewsAt}${x.noticeBy ? ` (notice by ${x.noticeBy})` : ""}` : ""}${x.cancelScheduledAt ? ` cancel scheduled${x.cancelsAt ? `, ends ${x.cancelsAt.slice(0, 10)}` : ""}` : ""} ${x.accountId}`
3500
+ )
3501
+ .join("\n")
3502
+ : `(nothing in the next ${r.days} days)`
3503
+ );
3504
+ }
3409
3505
  case "undo-churn": {
3410
3506
  // admin crm undo-churn <account-id> — clear the churn and move back to active.
3411
3507
  if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
@@ -3460,7 +3556,7 @@ async function cmdAdminCrm(ctx) {
3460
3556
  }
3461
3557
  default:
3462
3558
  throw new CliError(
3463
- "Usage: admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|draft-churn-reason|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
3559
+ "Usage: admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
3464
3560
  );
3465
3561
  }
3466
3562
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.115.0",
3
+ "version": "0.117.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",