@letterstory/cli 0.117.0 → 0.120.1

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.117.0";
63
+ export const VERSION = "0.120.1";
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
@@ -769,7 +769,7 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
769
769
  | import <file.csv> [--vendor Concentrate] [--area A] [--ledger-key K] [--dry-run]
770
770
  | import <file.csv> [--vendor Concentrate] [--area A] [--keep-ledger-key] [--dry-run]
771
771
  | usage <events.json> per-call costs as kernels/lettertrace push them
772
- | remove <entry-id> | services | service <slug> [--owner …] [--monthly N|none] [--renewal D]
772
+ | remove <entry-id> | services | service <slug> [--owner …] [--monthly N|none] [--renewal D] [--units N|none]
773
773
  Where Letter's money goes (spending.letter.dev):
774
774
  totals, run rate, area/vendor/provider/model/
775
775
  key/operation/org breakdowns, every number's
@@ -795,6 +795,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
795
795
  [--at ISO] [--mrr-before N] [--mrr-after N] [--note "…"] [--save-offer …]
796
796
  [--save-result …] [--draft]
797
797
  | renewals [--days 90]
798
+ | track-record [--since YYYY-MM-DD] | at-risk | expansion-signals
799
+ | case <account-id> [--outcome <outcome-id>]
798
800
  | onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
799
801
  [--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
800
802
  | onboarding <account-id> | retry-onboarding <onboarding-id>
@@ -820,6 +822,10 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
820
822
  reason; a key records drafts a person
821
823
  confirms. Renewals: contracts renewing
822
824
  within N days, and Stripe cancels scheduled.
825
+ Track record: how often each churn-risk and
826
+ next-product rule was right (rates show once
827
+ 15 outcomes and 90 days exist). At-risk and
828
+ expansion signals show the closest past case.
823
829
  Onboard runs org → link → phantom sites →
824
830
  Slack channel → checklist → app invites →
825
831
  stage in one call (--site "" = infer the
@@ -914,6 +920,8 @@ Seers (event-driven signals -> drafts):
914
920
  org export --split <dir> …as one file per record (for keeping config in git)
915
921
  org join <dir> [--file <path>] Rebuild the bundle from a --split directory
916
922
  org integrations Every integration and whether it's connected
923
+ org linear [teams [--team <id|key>] | set --team <id|key> [--project <id>|none] | disconnect]
924
+ Where Letter files Linear issues (connect in Settings)
917
925
  org diff <file|dir> What applying a bundle would change (reads only)
918
926
  org apply <file|dir> [--dry-run] [--prune --yes] Make the org match a bundle (adds/updates; --prune deletes)
919
927
  [--allow spends,sends,destroys,access --yes] Also apply gated changes (admins; without --yes it only previews)
@@ -1203,7 +1211,7 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub A
1203
1211
  repo branches --repo owner/name Branches a change can start from (default first)
1204
1212
  repo prs [--repo owner/name] Every open PR (whoever opened it), longest-idle first
1205
1213
  repo suggestions Fixes found and not acted on yet, and how to start each
1206
- repo settings [--action build|build_pr|build_merge|slack] [--branch <name>|none] [--slack-agent <name|U…>|none]
1214
+ repo settings [--action build|build_pr|build_merge|slack|linear] [--branch <name>|none] [--slack-agent <name|U…>|none]
1207
1215
  [--slack-channel <C…>|none] [--slack-reports off|as_found|daily] [--reports-channel <C…>|none] [--autopilot-channel <C…>|none] [--listen <C…,C…>|none] [--autopilot on|off] [--autopilot-weekly <1-20>]
1208
1216
  [--autopilot-severity critical|warning]
1209
1217
  The org's defaults for every fix: build (preview first),
@@ -1213,13 +1221,13 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub A
1213
1221
  repo slack-test Post a test into the Send to Slack channel
1214
1222
  repo start --repo <owner/name> --instructions <text>|--instructions-file <path|->
1215
1223
  [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]...
1216
- [--action build|build_pr|build_merge|slack] [--wait]
1224
+ [--action build|build_pr|build_merge|slack|linear] [--wait]
1217
1225
  Start a change (attached files go to the sandbox);
1218
1226
  --wait follows it to the PR
1219
- repo fix-audit --issue <issue-type>... [--audit <id>] [--branch <base>] [--action build|build_pr|build_merge|slack] [--wait]
1227
+ repo fix-audit --issue <issue-type>... [--audit <id>] [--branch <base>] [--action build|build_pr|build_merge|slack|linear] [--wait]
1220
1228
  One PR fixing site-audit issues (repeat --issue to bundle) on every affected page,
1221
1229
  in the repo verified to build the site
1222
- repo install-letterprove --repo <owner/name> [--branch <base>] [--action build|build_pr|build_merge|slack] [--wait]
1230
+ repo install-letterprove --repo <owner/name> [--branch <base>] [--action build|build_pr|build_merge|slack|linear] [--wait]
1223
1231
  One PR adding Letterprove's script and identify calls
1224
1232
  (pick the repo where users sign in)
1225
1233
  repo sites [--detect [--force]] Which website each repo builds, and the evidence
@@ -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|outcomes|record-outcome|renewals|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|track-record|at-risk|expansion-signals|case|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)
@@ -3502,6 +3502,79 @@ async function cmdAdminCrm(ctx) {
3502
3502
  : `(nothing in the next ${r.days} days)`
3503
3503
  );
3504
3504
  }
3505
+ case "track-record": {
3506
+ // admin crm track-record [--since YYYY-MM-DD] — how often each risk and next-product rule was right.
3507
+ const pct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
3508
+ const params = new URLSearchParams(compact({ since: flagStr(flags.since) }));
3509
+ return out(await staffRequest(ctx, "GET", `${CRM}/track-record?${params}`), (r) =>
3510
+ [
3511
+ `Since ${r.since ?? "—"} · confirmed: ${r.outcomes.churn} churn, ${r.outcomes.contraction} contraction, ${r.outcomes.expansion} expansion · rates show at ${r.minOutcomes} outcomes and ${r.windowDays} days`,
3512
+ `Base rate: loss ${pct(r.base.loss.rate)} · expansion ${pct(r.base.expansion.rate)}`,
3513
+ "",
3514
+ ...r.predictors.map(
3515
+ (p) =>
3516
+ `${p.key.padEnd(40)} flagged ${String(p.flagged).padStart(3)} matured ${String(p.matured).padStart(3)} hits ${String(p.hits).padStart(3)} missed ${String(p.missed).padStart(3)} rate ${pct(p.maturedRate.rate)} acted ${pct(p.acted.rate)} / not ${pct(p.notActed.rate)}${p.mature ? "" : ` (not mature: ${p.notMatureBecause})`}`
3517
+ ),
3518
+ ].join("\n")
3519
+ );
3520
+ }
3521
+ case "at-risk": {
3522
+ // admin crm at-risk — customers at watch or high, with the closest past churn.
3523
+ const caseLine = (c) =>
3524
+ c
3525
+ ? `\n like ${c.name} (${c.kind} ${c.on}${c.primaryReason ? `, ${c.primaryReason}` : ""}): ${c.matched}/5 matched — ${c.features
3526
+ .filter((f) => f.matched)
3527
+ .map((f) => `${f.feature} ${f.now}`)
3528
+ .join(", ")}`
3529
+ : "\n no past case matches 3 of 5";
3530
+ return out(await staffRequest(ctx, "GET", `${CRM}/at-risk`), (r) =>
3531
+ r.accounts.length
3532
+ ? r.accounts
3533
+ .map(
3534
+ (a) =>
3535
+ `${a.level.padEnd(5)} ${a.score.toFixed(2)} ${a.name} ${a.reasons.join("; ")} ${a.accountId}${caseLine(a.closestCase)}`
3536
+ )
3537
+ .join("\n")
3538
+ : "(nobody at watch or high)"
3539
+ );
3540
+ }
3541
+ case "expansion-signals": {
3542
+ // admin crm expansion-signals — next-product verdicts on the latest snapshot.
3543
+ return out(await staffRequest(ctx, "GET", `${CRM}/expansion-signals`), (r) =>
3544
+ r.signals.length
3545
+ ? r.signals
3546
+ .map(
3547
+ (x) =>
3548
+ `${x.score.toFixed(2)} ${x.name} → ${x.product} (${x.rule}) ${x.reasons.join("; ")} ${x.accountId}${x.beforePastExpansions.length ? `\n before past expansions: ${x.beforePastExpansions.map((p) => `${p.name} ${p.on}`).join(", ")}` : ""}`
3549
+ )
3550
+ .join("\n")
3551
+ : "(no next-product verdicts)"
3552
+ );
3553
+ }
3554
+ case "case": {
3555
+ // admin crm case <account-id> [--outcome <id>] — the 90 days before an outcome, or before today.
3556
+ if (!id) throw new CliError("Usage: admin crm case <account-id> [--outcome <outcome-id>]");
3557
+ const params = new URLSearchParams(compact({ outcomeId: flagStr(flags.outcome) }));
3558
+ const usd = (c) => `$${Math.round(c / 100).toLocaleString("en-US")}`;
3559
+ return out(await staffRequest(ctx, "GET", `${CRM}/accounts/${id}/case?${params}`), (r) =>
3560
+ [
3561
+ `${r.name} ${r.from} → ${r.until}${r.outcome ? ` ${outcomeLine(r.outcome)}` : ""}`,
3562
+ r.signalsBefore.length
3563
+ ? `Signals before: ${r.signalsBefore.map((x) => `${x.rule} from ${x.firstFiredOn} (${x.days}d)`).join(", ")}`
3564
+ : "Signals before: none",
3565
+ "",
3566
+ ...r.series.map(
3567
+ (p) =>
3568
+ `${p.on} ${usd(p.revenueCents)} posts ${p.phantomPosts30d} clicks ${p.clicks28d ?? "—"} cites ${p.aiCitations28d ?? "—"} waiting ${p.slackWaiting} quiet ${p.quietDays ?? "—"} ${p.riskLevel}`
3569
+ ),
3570
+ "",
3571
+ ...r.events.map(
3572
+ (e) =>
3573
+ `${e.at.slice(0, 10)} ${e.kind} ${e.title}${e.detail ? `\n ${e.detail}` : ""}${e.quotes.map((q) => `\n "${q}"`).join("")}`
3574
+ ),
3575
+ ].join("\n")
3576
+ );
3577
+ }
3505
3578
  case "undo-churn": {
3506
3579
  // admin crm undo-churn <account-id> — clear the churn and move back to active.
3507
3580
  if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
@@ -3556,7 +3629,7 @@ async function cmdAdminCrm(ctx) {
3556
3629
  }
3557
3630
  default:
3558
3631
  throw new CliError(
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)"
3632
+ "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|track-record|at-risk|expansion-signals|case|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
3560
3633
  );
3561
3634
  }
3562
3635
  }
@@ -4083,11 +4156,14 @@ async function cmdAdminSpend(ctx) {
4083
4156
  renewal_date: flagStr(flags.renewal),
4084
4157
  notes: flagStr(flags.notes),
4085
4158
  billing_source: oneOf(flags, "billing", ["api", "invoice", "unknown"]),
4159
+ units_purchased: flags.units === "none" ? undefined : flagNum(flags.units),
4160
+ unit_label: flagStr(flags["unit-label"]),
4086
4161
  });
4087
4162
  if (flags.monthly === "none") update.monthly_cost_usd = null;
4163
+ if (flags.units === "none") update.units_purchased = null;
4088
4164
  if (!slug || Object.keys(update).length === 0) {
4089
4165
  throw new CliError(
4090
- 'Usage: admin spend service <slug> [--owner "Steve"] [--plan "Pro"] [--monthly 20|none] [--renewal 2026-11-01] [--notes "…"] [--billing api|invoice|unknown]'
4166
+ 'Usage: admin spend service <slug> [--owner "Steve"] [--plan "Pro"] [--monthly 20|none] [--renewal 2026-11-01] [--notes "…"] [--billing api|invoice|unknown] [--units 30|none] [--unit-label "mailbox slots"]'
4091
4167
  );
4092
4168
  }
4093
4169
  const result = await staffRequest(ctx, "PATCH", `${SPEND}/services/${encodeURIComponent(slug)}`, update);
@@ -27,6 +27,13 @@ export const ARGS = {
27
27
  },
28
28
  get_org_config_source: {},
29
29
  list_integrations: {},
30
+ get_linear_connection: {},
31
+ list_linear_targets: { team_id: { flag: "team" } },
32
+ set_linear_target: {
33
+ team_id: { flag: "team", required: true, label: "team" },
34
+ project_id: { value: (flags) => (flags.project === "none" ? null : flagStr(flags.project)) },
35
+ },
36
+ disconnect_linear: {},
30
37
  connect_org_config_source: {
31
38
  repo: { positional: 0, label: "owner/name" },
32
39
  branch: { flag: "branch" },
@@ -56,9 +63,11 @@ export async function cmdOrg(ctx) {
56
63
  return orgGit(rest);
57
64
  case "integrations":
58
65
  return orgIntegrations(rest);
66
+ case "linear":
67
+ return orgLinear(rest);
59
68
  default:
60
69
  throw new CliError(
61
- `Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply, git, integrations`
70
+ `Unknown org subcommand: ${sub ?? "(none)"}. Try: export, join, diff, apply, git, integrations, linear`
62
71
  );
63
72
  }
64
73
  }
@@ -75,6 +84,53 @@ async function orgIntegrations(ctx) {
75
84
  return 0;
76
85
  }
77
86
 
87
+ /**
88
+ * `org linear` — where Letter files Linear issues (connecting is OAuth, in Settings):
89
+ * org linear the connection (get_linear_connection)
90
+ * org linear teams [--team <id|key>] teams, and a team's projects (list_linear_targets)
91
+ * org linear set --team <id|key> [--project <id>|none] (set_linear_target)
92
+ * org linear disconnect (disconnect_linear)
93
+ */
94
+ async function orgLinear(ctx) {
95
+ const { client, flags, io } = ctx;
96
+ const sub = ctx.positionals[0] ?? "show";
97
+ const call = async (tool, allowed) => {
98
+ refuseUnknownFlags(ctx, {}, allowed);
99
+ const result = await client.callTool(tool, argsFromFlags(ARGS[tool], flags, ctx.positionals.slice(1)));
100
+ ok(ctx, result.message);
101
+ return result;
102
+ };
103
+ switch (sub) {
104
+ case "show": {
105
+ const result = await call("get_linear_connection", []);
106
+ printResult(io, flags, result, (r) => r.message);
107
+ return 0;
108
+ }
109
+ case "teams": {
110
+ const result = await call("list_linear_targets", ["team"]);
111
+ printResult(io, flags, result, (r) =>
112
+ [
113
+ ...r.teams.map((t) => `${t.id === r.team_id ? "●" : "○"} ${t.key} ${t.name} (${t.id})`),
114
+ ...(r.projects.length ? ["", "Projects:", ...r.projects.map((p) => ` ${p.name} (${p.id})`)] : []),
115
+ ].join("\n")
116
+ );
117
+ return 0;
118
+ }
119
+ case "set": {
120
+ const result = await call("set_linear_target", ["team", "project"]);
121
+ printResult(io, flags, result, (r) => r.message);
122
+ return 0;
123
+ }
124
+ case "disconnect": {
125
+ const result = await call("disconnect_linear", []);
126
+ printResult(io, flags, result, (r) => r.message);
127
+ return 0;
128
+ }
129
+ default:
130
+ throw new CliError(`Unknown org linear subcommand: ${sub}. Try: teams, set, disconnect`);
131
+ }
132
+ }
133
+
78
134
  const ROOT_FILE = "organization.json";
79
135
 
80
136
  function slug(name) {
@@ -49,7 +49,7 @@ const ARGS = {
49
49
  instructions: { value: (flags) => readTextInput(flags, "instructions", "instructions-file") },
50
50
  // --attach <path> (uploaded first) and --attach-url <name>=<https url>, both repeatable.
51
51
  files: { value: (_flags, _positionals, extra) => (extra.files?.length ? extra.files : undefined) },
52
- action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack"] },
52
+ action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack", "linear"] },
53
53
  preview: { flag: "preview", type: "bool" },
54
54
  },
55
55
  create_repo_change_upload: { file_names: { internal: "the basenames of the --attach files" } },
@@ -74,21 +74,21 @@ const ARGS = {
74
74
  issue_types: { value: (flags) => (issueList(flags).length > 1 ? issueList(flags) : undefined) },
75
75
  audit_id: "audit",
76
76
  base_branch: "branch",
77
- action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack"] },
77
+ action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack", "linear"] },
78
78
  preview: { flag: "preview", type: "bool" },
79
79
  },
80
80
  letterprove_open_install_pr: {
81
81
  repo_owner: { value: (flags) => splitRepo(flags).owner },
82
82
  repo_name: { value: (flags) => splitRepo(flags).name },
83
83
  base_branch: "branch",
84
- action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack"] },
84
+ action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack", "linear"] },
85
85
  },
86
86
  get_repo_change_settings: {},
87
87
  list_suggested_fixes: {},
88
88
  list_slack_fix_targets: {},
89
89
  send_slack_test_message: {},
90
90
  set_repo_change_settings: {
91
- default_action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack"] },
91
+ default_action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack", "linear"] },
92
92
  // --branch <name>, or --branch none for each repo's own default.
93
93
  default_branch: { value: (flags) => (flags.branch === "none" ? null : flagStr(flags.branch)) },
94
94
  // --slack-agent Devin (or a U… member id), or --slack-agent none to tag nobody.
@@ -115,6 +115,8 @@ const ARGS = {
115
115
  .map((c) => c.trim())
116
116
  .filter(Boolean),
117
117
  },
118
+ // --linear-reports off|as_found: file each new finding as a Linear issue
119
+ linear_reports: { flag: "linear-reports", enum: ["off", "as_found"] },
118
120
  // --listen-all on|off: answer messages meant for Letter in every channel it's in
119
121
  slack_listen_all: {
120
122
  value: (flags) =>
@@ -177,7 +177,7 @@ const ARGS = {
177
177
  },
178
178
  base_branch: { flag: "base-branch" },
179
179
  preview: { flag: "preview", type: "bool" },
180
- action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack"] },
180
+ action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack", "linear"] },
181
181
  },
182
182
  };
183
183
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.117.0",
3
+ "version": "0.120.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",