@letterstory/cli 0.42.0 → 0.44.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 +10 -1
- package/lib/commands/admin.mjs +95 -2
- package/package.json +1 -1
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.
|
|
55
|
+
export const VERSION = "0.44.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.
|
|
@@ -561,10 +561,19 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
561
561
|
[--name …] [--email …] [--company …] [--domain …] [--linkedin …] [--detail …]
|
|
562
562
|
| signal <id> qualify|dismiss|link [--account <id>]
|
|
563
563
|
| accounts [--stage lead] [--q acme] | account <id> | score <account-id>
|
|
564
|
+
| slack-channels [--all] | link-slack <account-id> <channel-id|none>
|
|
565
|
+
| white-glove [--suggested] | set-white-glove <account-id> on|off [--note "…"]
|
|
566
|
+
| churn [--months 12] | record-churn <account-id> <reason> [--note "…"] [--at ISO]
|
|
567
|
+
[--mrr-cents N] | undo-churn <account-id>
|
|
564
568
|
The staff CRM's pipeline: the inbound signal
|
|
565
569
|
feed, recording a signal with no webhook
|
|
566
570
|
(Bookface, a tweet like), qualifying or
|
|
567
571
|
dismissing one, and the explained score.
|
|
572
|
+
White glove is a person's decision: the
|
|
573
|
+
desk only suggests ($1K+/mo post-sales).
|
|
574
|
+
Churn: 30/90-day logo + revenue rates, who
|
|
575
|
+
churned and why; Stripe cancellations are
|
|
576
|
+
only suggestions until a person records one.
|
|
568
577
|
Needs crm:read (reads) / crm:write (writes).
|
|
569
578
|
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
570
579
|
the pipeline as deployed, levers, measures,
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -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 … (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 … (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]
|
|
@@ -530,9 +530,102 @@ async function cmdAdminCrm(ctx) {
|
|
|
530
530
|
});
|
|
531
531
|
return out(r, () => (action === "none" ? "unlinked" : `linked ${action}`));
|
|
532
532
|
}
|
|
533
|
+
case "white-glove": {
|
|
534
|
+
// admin crm white-glove [--suggested] — the white-glove accounts, or the desk's suggestions.
|
|
535
|
+
const r = await staffRequest(ctx, "GET", `${CRM}/white-glove`);
|
|
536
|
+
if (flagBool(flags.suggested)) {
|
|
537
|
+
return out(r, (x) =>
|
|
538
|
+
x.suggested.length
|
|
539
|
+
? x.suggested.map((s) => `${accountLine(s.account)}\n ${s.reason}`).join("\n")
|
|
540
|
+
: "(no suggestions)"
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return out(r, (x) =>
|
|
544
|
+
x.accounts.length
|
|
545
|
+
? x.accounts
|
|
546
|
+
.map(
|
|
547
|
+
(a) =>
|
|
548
|
+
`${accountLine(a)}${a.whiteGloveAt ? `\n since ${a.whiteGloveAt.slice(0, 10)}` : ""}${a.whiteGloveNote ? ` · ${a.whiteGloveNote}` : ""}`
|
|
549
|
+
)
|
|
550
|
+
.join("\n")
|
|
551
|
+
: "(no white-glove accounts)"
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
case "set-white-glove": {
|
|
555
|
+
// admin crm set-white-glove <account-id> on|off [--note "SLA 1h, weekly check-in"]
|
|
556
|
+
if (!id || !["on", "off"].includes(action)) {
|
|
557
|
+
throw new CliError(
|
|
558
|
+
'Usage: admin crm set-white-glove <account-id> on|off [--note "SLA 1h, weekly check-in"]'
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
const note = flagStr(flags.note);
|
|
562
|
+
const r = await staffRequest(
|
|
563
|
+
ctx,
|
|
564
|
+
"PATCH",
|
|
565
|
+
`${CRM}/accounts/${id}`,
|
|
566
|
+
compact({ white_glove: action === "on", white_glove_note: note })
|
|
567
|
+
);
|
|
568
|
+
return out(r, () => (action === "on" ? "moved to white glove" : "left white glove"));
|
|
569
|
+
}
|
|
570
|
+
case "churn": {
|
|
571
|
+
// admin crm churn [--months 12] — the 30/90-day rates, who churned and why, likely churns.
|
|
572
|
+
const params = new URLSearchParams(compact({ months: flagStr(flags.months) }));
|
|
573
|
+
const pct = (x) => (x == null ? "—" : `${(x * 100).toFixed(1)}%`);
|
|
574
|
+
const usd = (cents) => `$${Math.round(cents / 100).toLocaleString("en-US")}`;
|
|
575
|
+
const win = (label, w) =>
|
|
576
|
+
`${label}: ${w.churned}/${w.customersAtStart} customers (${pct(w.rate)}) · ${usd(w.mrrLostCents)}/mo lost (${pct(w.revenueRate)} of MRR)`;
|
|
577
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/churn?${params}`), (r) =>
|
|
578
|
+
[
|
|
579
|
+
win("30d", r.window.days30),
|
|
580
|
+
win("90d", r.window.days90),
|
|
581
|
+
"",
|
|
582
|
+
"Churned:",
|
|
583
|
+
...(r.churned.length
|
|
584
|
+
? r.churned.map(
|
|
585
|
+
(c) =>
|
|
586
|
+
` ${c.churnedAt.slice(0, 10)}${c.churnedAtSource === "undated" ? "?" : ""} ${c.account.name} ${c.reason ?? "unrecorded"} ${usd(c.mrrLostCents)}/mo${c.note ? ` — ${c.note}` : ""}`
|
|
587
|
+
)
|
|
588
|
+
: [" (none)"]),
|
|
589
|
+
"",
|
|
590
|
+
"Likely churned (not recorded):",
|
|
591
|
+
...(r.likely.length
|
|
592
|
+
? r.likely.map((l) => ` ${accountLine(l.account)}\n ${l.signal}`)
|
|
593
|
+
: [" (none)"]),
|
|
594
|
+
].join("\n")
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
case "record-churn": {
|
|
598
|
+
// admin crm record-churn <account-id> <reason> [--note "…"] [--at ISO] [--mrr-cents N]
|
|
599
|
+
if (!id || !action) {
|
|
600
|
+
throw new CliError(
|
|
601
|
+
'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]'
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
const r = await staffPost(
|
|
605
|
+
ctx,
|
|
606
|
+
`${CRM}/accounts/${id}/churn`,
|
|
607
|
+
compact({
|
|
608
|
+
reason: action,
|
|
609
|
+
note: flagStr(flags.note),
|
|
610
|
+
churned_at: flagStr(flags.at),
|
|
611
|
+
mrr_cents: flagNum(flags["mrr-cents"]),
|
|
612
|
+
})
|
|
613
|
+
);
|
|
614
|
+
return out(
|
|
615
|
+
r,
|
|
616
|
+
(x) =>
|
|
617
|
+
`${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`}`
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
case "undo-churn": {
|
|
621
|
+
// admin crm undo-churn <account-id> — clear the churn and move back to active.
|
|
622
|
+
if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
|
|
623
|
+
const r = await staffRequest(ctx, "DELETE", `${CRM}/accounts/${id}/churn`);
|
|
624
|
+
return out(r, (x) => `churn undone: ${accountLine(x.account)}`);
|
|
625
|
+
}
|
|
533
626
|
default:
|
|
534
627
|
throw new CliError(
|
|
535
|
-
"Usage: admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack … (see --help)"
|
|
628
|
+
"Usage: admin crm signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn … (see --help)"
|
|
536
629
|
);
|
|
537
630
|
}
|
|
538
631
|
}
|