@letterstory/cli 0.114.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.114.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>
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,8 +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.
815
+ draft-churn-reason drafts a reason from
816
+ what the CRM kept; a person confirms it in
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.
805
823
  Onboard runs org → link → phantom sites →
806
824
  Slack channel → checklist → app invites →
807
825
  stage in one call (--site "" = infer the
@@ -1403,7 +1421,9 @@ export async function run(argv, io = defaultIo()) {
1403
1421
  await resolveDocumentFlags(client, flags);
1404
1422
 
1405
1423
  const ctx = { client, config, positionals: positionals.slice(1), flags, io, bin };
1406
- return await handler(ctx);
1424
+ // A handler may return what its last `io.log` returned (`true`): only a number is an exit code.
1425
+ const code = await handler(ctx);
1426
+ return typeof code === "number" ? code : 0;
1407
1427
  } catch (err) {
1408
1428
  if (err instanceof CliError) {
1409
1429
  io.error(`Error: ${err.message}`);
@@ -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|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)
@@ -44,7 +44,17 @@
44
44
 
45
45
  import { readFileSync } from "node:fs";
46
46
  import { CliError, LetterstoryClient } from "../client.mjs";
47
- import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok, requirePositional } from "./shared.mjs";
47
+ import {
48
+ flagStr,
49
+ flagNum,
50
+ flagBool,
51
+ flagList,
52
+ flagCsv,
53
+ printResult,
54
+ compact,
55
+ ok,
56
+ requirePositional,
57
+ } from "./shared.mjs";
48
58
  import { watchDomainJob } from "./onboard.mjs";
49
59
 
50
60
  const SEER_MODES = ["suggest", "skip"];
@@ -682,6 +692,21 @@ async function cmdAdminTuningContext(ctx) {
682
692
  * admin tuning enhance-inputs [--set v1] | enhance-split [--since-hours 72] [--at <iso>,…] | enhance-standing
683
693
  * admin tuning promote-enhancer <experiment-id> --version <enhancer-version-id> promote our enhancer from a "ship" decision (picks when it changes visuals)
684
694
  */
695
+ /** `--flag '<json array>'` → the parsed array of objects, or undefined when the flag is absent. */
696
+ function jsonArrayFlag(raw, name) {
697
+ const text = flagStr(raw);
698
+ if (text === undefined) return undefined;
699
+ let v;
700
+ try {
701
+ v = JSON.parse(text);
702
+ } catch (err) {
703
+ throw new CliError(`${name} is not JSON: ${err.message}`);
704
+ }
705
+ if (!Array.isArray(v) || v.some((x) => !x || typeof x !== "object" || Array.isArray(x)))
706
+ throw new CliError(`${name} is a JSON array of objects: '[{…},{…}]'`);
707
+ return v;
708
+ }
709
+
685
710
  /** `--flag '<json>'` → the parsed object, or undefined when the flag is absent. */
686
711
  function jsonFlag(raw, name) {
687
712
  const text = flagStr(raw);
@@ -846,7 +871,7 @@ async function cmdAdminTuning(ctx) {
846
871
  }
847
872
  case "compare": {
848
873
  const title = flagStr(flags.title);
849
- const list = flagList(flags.candidates);
874
+ const list = flagCsv(flags.candidates);
850
875
  if (!title || !list?.length)
851
876
  throw new CliError(
852
877
  'Usage: admin tuning compare --title "…" --candidates Hazel:21,Aspen:20 [--experiment <id>] [--pairs 20] (default 10 pairs)'
@@ -2178,10 +2203,8 @@ async function cmdAdminTuning(ctx) {
2178
2203
  const versionId = flagStr(flags.candidate);
2179
2204
  const patch = jsonFlag(flags.patch, "--patch");
2180
2205
  const nullCheck = flagBool(flags["null-check"]);
2181
- const versionIds = flagList(flags.candidates);
2182
- const patches = jsonFlag(flags.patches, "--patches");
2183
- if (patches !== undefined && !Array.isArray(patches))
2184
- throw new CliError("--patches is a JSON array of patches: '[{…},{…}]'");
2206
+ const versionIds = flagCsv(flags.candidates);
2207
+ const patches = jsonArrayFlag(flags.patches, "--patches");
2185
2208
  const named = [
2186
2209
  versionId !== undefined,
2187
2210
  patch !== undefined,
@@ -2217,7 +2240,7 @@ async function cmdAdminTuning(ctx) {
2217
2240
  candidates: several,
2218
2241
  // The kernel that writes every arm into a finished article (the arena the only difference).
2219
2242
  write: flagStr(flags.write) ? { kernel: flagStr(flags.write) } : undefined,
2220
- keys: flagList(flags.keys),
2243
+ keys: flagCsv(flags.keys),
2221
2244
  concurrency: flagNum(flags.concurrency),
2222
2245
  experiment_id: flagStr(flags.experiment),
2223
2246
  })
@@ -2244,7 +2267,7 @@ async function cmdAdminTuning(ctx) {
2244
2267
  case "comparison-extend": {
2245
2268
  // A Write comparison grows by the next N pairs; one on fixed inputs by named keys or all.
2246
2269
  const pairs = flagNum(flags.pairs);
2247
- const keys = flagBool(flags.all) ? "all" : flagList(flags.keys);
2270
+ const keys = flagBool(flags.all) ? "all" : flagCsv(flags.keys);
2248
2271
  const usage =
2249
2272
  "Usage: admin tuning comparison-extend <comparison-id> --pairs N | --keys k1 --keys k2 | --all";
2250
2273
  if (!id || (pairs === undefined && !keys?.length) || (pairs !== undefined && keys?.length))
@@ -2304,7 +2327,7 @@ async function cmdAdminTuning(ctx) {
2304
2327
  primitive: "shred",
2305
2328
  title,
2306
2329
  candidate,
2307
- keys: flagList(flags.keys),
2330
+ keys: flagCsv(flags.keys),
2308
2331
  concurrency: flagNum(flags.concurrency),
2309
2332
  experiment_id: flagStr(flags.experiment),
2310
2333
  })
@@ -2422,7 +2445,7 @@ async function cmdAdminTuning(ctx) {
2422
2445
  primitive: "enhance",
2423
2446
  title,
2424
2447
  candidate,
2425
- keys: flagList(flags.keys),
2448
+ keys: flagCsv(flags.keys),
2426
2449
  concurrency: flagNum(flags.concurrency),
2427
2450
  experiment_id: flagStr(flags.experiment),
2428
2451
  })
@@ -2539,6 +2562,16 @@ function signalLine(s) {
2539
2562
  return `${s.id} [${s.status} · ${s.intent}] ${s.occurredAt.slice(0, 10)} ${s.source}/${s.kind} ${s.title}${who ? `\n ${who}` : ""}`;
2540
2563
  }
2541
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
+
2542
2575
  function accountLine(a) {
2543
2576
  const score = a.likelihood == null ? "—" : a.likelihood.toFixed(2);
2544
2577
  return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
@@ -3299,12 +3332,23 @@ async function cmdAdminCrm(ctx) {
3299
3332
  return out(r, () => (action === "on" ? "moved to white glove" : "left white glove"));
3300
3333
  }
3301
3334
  case "churn": {
3302
- // 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.
3303
3337
  const params = new URLSearchParams(compact({ months: flagStr(flags.months) }));
3304
3338
  const pct = (x) => (x == null ? "—" : `${(x * 100).toFixed(1)}%`);
3305
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;
3306
3344
  const win = (label, w) =>
3307
- `${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}` : ""}]` : "");
3308
3352
  return out(await staffRequest(ctx, "GET", `${CRM}/churn?${params}`), (r) =>
3309
3353
  [
3310
3354
  win("30d", r.window.days30),
@@ -3314,14 +3358,24 @@ async function cmdAdminCrm(ctx) {
3314
3358
  ...(r.churned.length
3315
3359
  ? r.churned.map(
3316
3360
  (c) =>
3317
- ` ${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}` : ""}`
3318
3362
  )
3319
3363
  : [" (none)"]),
3320
3364
  "",
3321
3365
  "Likely churned (not recorded):",
3322
3366
  ...(r.likely.length
3323
- ? r.likely.map((l) => ` ${accountLine(l.account)}\n ${l.signal}`)
3367
+ ? r.likely.map((l) => ` ${accountLine(l.account)}${cause(l)}\n ${l.signal}`)
3324
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)"]),
3325
3379
  ].join("\n")
3326
3380
  );
3327
3381
  }
@@ -3358,6 +3412,8 @@ async function cmdAdminCrm(ctx) {
3358
3412
  note: flagStr(flags.note),
3359
3413
  churned_at: flagStr(flags.at),
3360
3414
  mrr_cents: flagNum(flags["mrr-cents"]),
3415
+ cause: flagStr(flags.cause),
3416
+ notice_at: flagStr(flags["notice-at"]),
3361
3417
  })
3362
3418
  );
3363
3419
  return out(
@@ -3366,6 +3422,86 @@ async function cmdAdminCrm(ctx) {
3366
3422
  `${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`}`
3367
3423
  );
3368
3424
  }
3425
+ case "draft-churn-reason": {
3426
+ // admin crm draft-churn-reason <account-id> — draft why a churned account left, from
3427
+ // what the CRM kept. The draft waits in the proposals queue for a person to confirm.
3428
+ if (!id) throw new CliError("Usage: admin crm draft-churn-reason <account-id>");
3429
+ const r = await staffPost(ctx, `${CRM}/accounts/${id}/churn/draft-reason`, {});
3430
+ return out(r, (x) =>
3431
+ x.status === "drafted"
3432
+ ? [
3433
+ `drafted: ${x.reason}${x.contributing.length ? ` (also ${x.contributing.join(", ")})` : ""} · ${x.cause} · proposal ${x.proposalId}`,
3434
+ ` "${x.quote}"`,
3435
+ x.summary ? ` ${x.summary}` : null,
3436
+ ]
3437
+ .filter(Boolean)
3438
+ .join("\n")
3439
+ : `${x.status.replaceAll("_", " ")}${x.detail ? ` (${x.detail.replaceAll("_", " ")})` : ""}`
3440
+ );
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
+ }
3369
3505
  case "undo-churn": {
3370
3506
  // admin crm undo-churn <account-id> — clear the churn and move back to active.
3371
3507
  if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
@@ -3420,7 +3556,7 @@ async function cmdAdminCrm(ctx) {
3420
3556
  }
3421
3557
  default:
3422
3558
  throw new CliError(
3423
- "Usage: admin crm import|capture|outreach|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)"
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)"
3424
3560
  );
3425
3561
  }
3426
3562
  }
@@ -32,6 +32,19 @@ export function flagList(v) {
32
32
  return typeof v === "string" ? [v] : [];
33
33
  }
34
34
 
35
+ /**
36
+ * A list of ids or keys, repeatable AND comma-separated: `--keys a,b --keys c` → `["a","b","c"]`.
37
+ * `flagList` keeps each occurrence whole (free text such as `--stance` may carry a comma); the id
38
+ * and key flags whose usage says `k1,k2` read through this instead, or the whole string went to the
39
+ * server as one id and failed validation.
40
+ */
41
+ export function flagCsv(v) {
42
+ return flagList(v)
43
+ .flatMap((x) => x.split(","))
44
+ .map((x) => x.trim())
45
+ .filter(Boolean);
46
+ }
47
+
35
48
  /**
36
49
  * `--prefer <site>` (repeatable or comma-separated) → preferred_sources `[{ url }]` (a site; a URL is read as its site); `--prefer none`
37
50
  * → `[]` (clears a standing list); absent → undefined.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.114.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",