@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 +11 -0
- package/lib/cli.mjs +18 -2
- package/lib/commands/admin.mjs +129 -2
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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.
|
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|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
|
}
|