@letterstory/cli 0.115.0 → 0.120.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.115.0";
63
+ export const VERSION = "0.120.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,15 @@ 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> | draft-churn-reason <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]
798
+ | track-record [--since YYYY-MM-DD] | at-risk | expansion-signals
799
+ | case <account-id> [--outcome <outcome-id>]
790
800
  | onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
791
801
  [--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
792
802
  | onboarding <account-id> | retry-onboarding <onboarding-id>
@@ -800,11 +810,22 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
800
810
  White glove is a person's decision: the
801
811
  desk only suggests ($1K+/mo post-sales).
802
812
  Churn: 30/90-day logo + revenue rates, who
803
- churned and why; Stripe cancellations are
804
- only suggestions until a person records one.
813
+ churned and why, split voluntary /
814
+ involuntary, and payments failing now;
815
+ Stripe cancellations are only suggestions
816
+ until a person records one.
805
817
  draft-churn-reason drafts a reason from
806
818
  what the CRM kept; a person confirms it in
807
819
  the proposals queue.
820
+ Outcomes keep every churn, contraction,
821
+ expansion, reactivation and save with its
822
+ reason; a key records drafts a person
823
+ confirms. Renewals: contracts renewing
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.
808
829
  Onboard runs org → link → phantom sites →
809
830
  Slack channel → checklist → app invites →
810
831
  stage in one call (--site "" = infer the
@@ -899,6 +920,8 @@ Seers (event-driven signals -> drafts):
899
920
  org export --split <dir> …as one file per record (for keeping config in git)
900
921
  org join <dir> [--file <path>] Rebuild the bundle from a --split directory
901
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)
902
925
  org diff <file|dir> What applying a bundle would change (reads only)
903
926
  org apply <file|dir> [--dry-run] [--prune --yes] Make the org match a bundle (adds/updates; --prune deletes)
904
927
  [--allow spends,sends,destroys,access --yes] Also apply gated changes (admins; without --yes it only previews)
@@ -1188,7 +1211,7 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub A
1188
1211
  repo branches --repo owner/name Branches a change can start from (default first)
1189
1212
  repo prs [--repo owner/name] Every open PR (whoever opened it), longest-idle first
1190
1213
  repo suggestions Fixes found and not acted on yet, and how to start each
1191
- 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]
1192
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>]
1193
1216
  [--autopilot-severity critical|warning]
1194
1217
  The org's defaults for every fix: build (preview first),
@@ -1198,13 +1221,13 @@ Repo changes (Claude Code edits your repo in a sandbox; the Letterstory GitHub A
1198
1221
  repo slack-test Post a test into the Send to Slack channel
1199
1222
  repo start --repo <owner/name> --instructions <text>|--instructions-file <path|->
1200
1223
  [--branch <base>] [--attach <path>]... [--attach-url <name>=<https url>]...
1201
- [--action build|build_pr|build_merge|slack] [--wait]
1224
+ [--action build|build_pr|build_merge|slack|linear] [--wait]
1202
1225
  Start a change (attached files go to the sandbox);
1203
1226
  --wait follows it to the PR
1204
- 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]
1205
1228
  One PR fixing site-audit issues (repeat --issue to bundle) on every affected page,
1206
1229
  in the repo verified to build the site
1207
- 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]
1208
1231
  One PR adding Letterprove's script and identify calls
1209
1232
  (pick the repo where users sign in)
1210
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|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)
@@ -2562,6 +2562,16 @@ function signalLine(s) {
2562
2562
  return `${s.id} [${s.status} · ${s.intent}] ${s.occurredAt.slice(0, 10)} ${s.source}/${s.kind} ${s.title}${who ? `\n ${who}` : ""}`;
2563
2563
  }
2564
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
+
2565
2575
  function accountLine(a) {
2566
2576
  const score = a.likelihood == null ? "—" : a.likelihood.toFixed(2);
2567
2577
  return `${a.id} [${a.stage}] ${score} ${a.name}${a.domain ? ` (${a.domain})` : ""}${a.isYc ? " · YC" : ""}`;
@@ -3322,12 +3332,23 @@ async function cmdAdminCrm(ctx) {
3322
3332
  return out(r, () => (action === "on" ? "moved to white glove" : "left white glove"));
3323
3333
  }
3324
3334
  case "churn": {
3325
- // 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.
3326
3337
  const params = new URLSearchParams(compact({ months: flagStr(flags.months) }));
3327
3338
  const pct = (x) => (x == null ? "—" : `${(x * 100).toFixed(1)}%`);
3328
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;
3329
3344
  const win = (label, w) =>
3330
- `${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}` : ""}]` : "");
3331
3352
  return out(await staffRequest(ctx, "GET", `${CRM}/churn?${params}`), (r) =>
3332
3353
  [
3333
3354
  win("30d", r.window.days30),
@@ -3337,14 +3358,24 @@ async function cmdAdminCrm(ctx) {
3337
3358
  ...(r.churned.length
3338
3359
  ? r.churned.map(
3339
3360
  (c) =>
3340
- ` ${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}` : ""}`
3341
3362
  )
3342
3363
  : [" (none)"]),
3343
3364
  "",
3344
3365
  "Likely churned (not recorded):",
3345
3366
  ...(r.likely.length
3346
- ? r.likely.map((l) => ` ${accountLine(l.account)}\n ${l.signal}`)
3367
+ ? r.likely.map((l) => ` ${accountLine(l.account)}${cause(l)}\n ${l.signal}`)
3347
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)"]),
3348
3379
  ].join("\n")
3349
3380
  );
3350
3381
  }
@@ -3381,6 +3412,8 @@ async function cmdAdminCrm(ctx) {
3381
3412
  note: flagStr(flags.note),
3382
3413
  churned_at: flagStr(flags.at),
3383
3414
  mrr_cents: flagNum(flags["mrr-cents"]),
3415
+ cause: flagStr(flags.cause),
3416
+ notice_at: flagStr(flags["notice-at"]),
3384
3417
  })
3385
3418
  );
3386
3419
  return out(
@@ -3406,6 +3439,142 @@ async function cmdAdminCrm(ctx) {
3406
3439
  : `${x.status.replaceAll("_", " ")}${x.detail ? ` (${x.detail.replaceAll("_", " ")})` : ""}`
3407
3440
  );
3408
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
+ }
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
+ }
3409
3578
  case "undo-churn": {
3410
3579
  // admin crm undo-churn <account-id> — clear the churn and move back to active.
3411
3580
  if (!id) throw new CliError("Usage: admin crm undo-churn <account-id>");
@@ -3460,7 +3629,7 @@ async function cmdAdminCrm(ctx) {
3460
3629
  }
3461
3630
  default:
3462
3631
  throw new CliError(
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)"
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)"
3464
3633
  );
3465
3634
  }
3466
3635
  }
@@ -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.115.0",
3
+ "version": "0.120.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",