@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 +31 -8
- package/lib/commands/admin.mjs +175 -6
- package/lib/commands/org.mjs +57 -1
- package/lib/commands/repo.mjs +6 -4
- package/lib/commands/site.mjs +1 -1
- package/package.json +1 -1
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.
|
|
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
|
|
804
|
-
|
|
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
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/lib/commands/org.mjs
CHANGED
|
@@ -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) {
|
package/lib/commands/repo.mjs
CHANGED
|
@@ -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) =>
|
package/lib/commands/site.mjs
CHANGED
|
@@ -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
|
|