@letterstory/cli 0.113.1 → 0.115.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.113.1";
63
+ export const VERSION = "0.115.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
@@ -786,7 +786,7 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
786
786
  | slack-channels [--all] | link-slack <account-id> <channel-id|none>
787
787
  | white-glove [--suggested] | set-white-glove <account-id> on|off [--note "…"]
788
788
  | churn [--months 12] | record-churn <account-id> <reason> [--note "…"] [--at ISO]
789
- [--mrr-cents N] | undo-churn <account-id>
789
+ [--mrr-cents N] | undo-churn <account-id> | draft-churn-reason <account-id>
790
790
  | onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
791
791
  [--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
792
792
  | onboarding <account-id> | retry-onboarding <onboarding-id>
@@ -802,6 +802,9 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
802
802
  Churn: 30/90-day logo + revenue rates, who
803
803
  churned and why; Stripe cancellations are
804
804
  only suggestions until a person records one.
805
+ draft-churn-reason drafts a reason from
806
+ what the CRM kept; a person confirms it in
807
+ the proposals queue.
805
808
  Onboard runs org → link → phantom sites →
806
809
  Slack channel → checklist → app invites →
807
810
  stage in one call (--site "" = infer the
@@ -1403,7 +1406,9 @@ export async function run(argv, io = defaultIo()) {
1403
1406
  await resolveDocumentFlags(client, flags);
1404
1407
 
1405
1408
  const ctx = { client, config, positionals: positionals.slice(1), flags, io, bin };
1406
- return await handler(ctx);
1409
+ // A handler may return what its last `io.log` returned (`true`): only a number is an exit code.
1410
+ const code = await handler(ctx);
1411
+ return typeof code === "number" ? code : 0;
1407
1412
  } catch (err) {
1408
1413
  if (err instanceof CliError) {
1409
1414
  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|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
  })
@@ -3366,6 +3389,23 @@ async function cmdAdminCrm(ctx) {
3366
3389
  `${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
3390
  );
3368
3391
  }
3392
+ case "draft-churn-reason": {
3393
+ // admin crm draft-churn-reason <account-id> — draft why a churned account left, from
3394
+ // what the CRM kept. The draft waits in the proposals queue for a person to confirm.
3395
+ if (!id) throw new CliError("Usage: admin crm draft-churn-reason <account-id>");
3396
+ const r = await staffPost(ctx, `${CRM}/accounts/${id}/churn/draft-reason`, {});
3397
+ return out(r, (x) =>
3398
+ x.status === "drafted"
3399
+ ? [
3400
+ `drafted: ${x.reason}${x.contributing.length ? ` (also ${x.contributing.join(", ")})` : ""} · ${x.cause} · proposal ${x.proposalId}`,
3401
+ ` "${x.quote}"`,
3402
+ x.summary ? ` ${x.summary}` : null,
3403
+ ]
3404
+ .filter(Boolean)
3405
+ .join("\n")
3406
+ : `${x.status.replaceAll("_", " ")}${x.detail ? ` (${x.detail.replaceAll("_", " ")})` : ""}`
3407
+ );
3408
+ }
3369
3409
  case "undo-churn": {
3370
3410
  // admin crm undo-churn <account-id> — clear the churn and move back to active.
3371
3411
  if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
@@ -3420,7 +3460,7 @@ async function cmdAdminCrm(ctx) {
3420
3460
  }
3421
3461
  default:
3422
3462
  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)"
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)"
3424
3464
  );
3425
3465
  }
3426
3466
  }
@@ -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.113.1",
3
+ "version": "0.115.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",