@letterstory/cli 0.128.3 → 0.129.2
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 +8 -2
- package/lib/commands/admin.mjs +53 -11
- package/lib/commands/site.mjs +12 -2
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -62,7 +62,7 @@ import {
|
|
|
62
62
|
} from "./commands.mjs";
|
|
63
63
|
|
|
64
64
|
// Keep in sync with cli/package.json.
|
|
65
|
-
export const VERSION = "0.
|
|
65
|
+
export const VERSION = "0.129.2";
|
|
66
66
|
|
|
67
67
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
68
68
|
// can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
|
|
@@ -923,8 +923,10 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
923
923
|
| renewals [--days 90]
|
|
924
924
|
| reach-outs [--day YYYY-MM-DD] [--fresh] the day's reach-outs, N of 5 (#crm-reminders' count)
|
|
925
925
|
| track-record [--since YYYY-MM-DD] | at-risk | expansion-signals
|
|
926
|
+
| weekly-note the Monday retention note for the last full week
|
|
926
927
|
| collections [--org-ids a,b | --account-stages won,active]
|
|
927
|
-
| delivery-flags [--org-ids a,b | --account-stages won,active] [--include collections]
|
|
928
|
+
| delivery-flags [--org-ids a,b | --account-stages won,active] [--include collections] [--since 2026-10-19T20:00:00Z]
|
|
929
|
+
| pulse [--ids a,b] [--since 2026-10-19T20:00:00Z] how each client is doing, worst churn risk first
|
|
928
930
|
| case <account-id> [--outcome <outcome-id>]
|
|
929
931
|
| onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
|
|
930
932
|
[--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
|
|
@@ -955,6 +957,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
955
957
|
next-product rule was right (rates show once
|
|
956
958
|
15 outcomes and 90 days exist). At-risk and
|
|
957
959
|
expansion signals show the closest past case.
|
|
960
|
+
Weekly note: the note #crm-opportunities
|
|
961
|
+
gets on Mondays (reads only).
|
|
958
962
|
Onboard runs org → link → phantom sites →
|
|
959
963
|
Slack channel → checklist → app invites →
|
|
960
964
|
stage in one call (--site "" = infer the
|
|
@@ -1333,6 +1337,8 @@ The Site (one model of your whole marketing site, keyed by host and path):
|
|
|
1333
1337
|
site propose <host> [path] --tool <tool> --input '<json>'|--input-file <p> --label <text> [--why <text>]
|
|
1334
1338
|
[--observation <id>]... Propose a registry tool call; input is validated now
|
|
1335
1339
|
site decide <proposal-id> --decision accept|dismiss [--reason <why>]
|
|
1340
|
+
site fix <host> <observation-id>... [--action build|build_pr|build_merge|slack|linear|handoff] [--with-preview]
|
|
1341
|
+
One change for N observations; handoff prints the prompt, slack/linear need no repo
|
|
1336
1342
|
site competitors [--since <iso> | --days <n>] [--limit <n>] Tracked competitors' sites: new and removed pages, by folder
|
|
1337
1343
|
site watch-competitors [domain] [--limit <n>] Map competitors' sitemaps (the next few unmapped, or one now)
|
|
1338
1344
|
site highlight <domain> [--off] Highlight a competitor; its changes lead the top changes
|
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|reach-outs|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|track-record|at-risk|expansion-signals|collections|delivery-flags|case|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|reach-outs|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|track-record|weekly-note|at-risk|expansion-signals|collections|delivery-flags|pulse|case|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
|
|
18
18
|
// admin spend summary|reconcile|pnl|margin|pricing|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)
|
|
@@ -3523,6 +3523,13 @@ async function cmdAdminCrm(ctx) {
|
|
|
3523
3523
|
: `(nothing in the next ${r.days} days)`
|
|
3524
3524
|
);
|
|
3525
3525
|
}
|
|
3526
|
+
case "weekly-note": {
|
|
3527
|
+
// admin crm weekly-note — the Monday retention note for the last full ISO week (reads only; never posts).
|
|
3528
|
+
return out(
|
|
3529
|
+
await staffRequest(ctx, "GET", `${CRM}/weekly-note`),
|
|
3530
|
+
(r) => `${r.week.key} (${r.week.from} to ${r.week.to})\n\n${r.text}`
|
|
3531
|
+
);
|
|
3532
|
+
}
|
|
3526
3533
|
case "track-record": {
|
|
3527
3534
|
// admin crm track-record [--since YYYY-MM-DD] — how often each risk and next-product rule was right.
|
|
3528
3535
|
const pct = (x) => (x == null ? "—" : `${Math.round(x * 100)}%`);
|
|
@@ -3589,27 +3596,62 @@ async function cmdAdminCrm(ctx) {
|
|
|
3589
3596
|
);
|
|
3590
3597
|
}
|
|
3591
3598
|
case "delivery-flags": {
|
|
3592
|
-
// admin crm delivery-flags [--org-ids a,b | --account-stages won,active] [--include collections]
|
|
3599
|
+
// admin crm delivery-flags [--org-ids a,b | --account-stages won,active] [--include collections] [--since <ISO time, e.g. 2026-10-19T20:00:00Z>]
|
|
3593
3600
|
// — does what we spend on a client match what they pay? Findings per org, worst first.
|
|
3601
|
+
// The post-sales stages come from a snapshot (~10 min); --since skips one older than that.
|
|
3594
3602
|
const params = new URLSearchParams(
|
|
3595
3603
|
compact({
|
|
3596
3604
|
org_ids: flagStr(flags["org-ids"]),
|
|
3597
3605
|
account_stages: flagStr(flags["account-stages"]),
|
|
3598
3606
|
include: flagStr(flags.include),
|
|
3607
|
+
since: flagStr(flags.since),
|
|
3599
3608
|
})
|
|
3600
3609
|
);
|
|
3601
3610
|
return out(await staffRequest(ctx, "GET", `${CRM}/delivery-flags?${params}`), (r) => {
|
|
3602
3611
|
if (!r.accounts)
|
|
3603
3612
|
return "Billing cannot say who pays yet, so there are no flags (collections are in --json).";
|
|
3604
3613
|
const flagged = r.accounts.filter((a) => a.findings.length);
|
|
3605
|
-
|
|
3606
|
-
?
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3614
|
+
const asOf = r.snapshot
|
|
3615
|
+
? `(as of ${r.snapshot.computedAt}${r.snapshot.stale ? ", STALE: the refresh is failing" : ""})\n`
|
|
3616
|
+
: "";
|
|
3617
|
+
return (
|
|
3618
|
+
asOf +
|
|
3619
|
+
(flagged.length
|
|
3620
|
+
? flagged
|
|
3621
|
+
.map(
|
|
3622
|
+
(a) =>
|
|
3623
|
+
`${a.owedDelivery ? "owed " : a.notPaying ? "unpaid" : " "} ${a.orgName} ${a.findings.map((f) => f.message).join("; ")} ${a.orgId}`
|
|
3624
|
+
)
|
|
3625
|
+
.join("\n")
|
|
3626
|
+
: `(${r.accounts.length} org${r.accounts.length === 1 ? "" : "s"}, nothing flagged)`)
|
|
3627
|
+
);
|
|
3628
|
+
});
|
|
3629
|
+
}
|
|
3630
|
+
case "pulse": {
|
|
3631
|
+
// admin crm pulse [--ids a,b] [--since <ISO time, e.g. 2026-10-19T20:00:00Z>]
|
|
3632
|
+
// — how is each client doing? One line per account, worst churn risk first.
|
|
3633
|
+
// The default scope comes from a snapshot (~10 min); --since skips one older than that.
|
|
3634
|
+
const params = new URLSearchParams(compact({ ids: flagStr(flags.ids), since: flagStr(flags.since) }));
|
|
3635
|
+
return out(await staffRequest(ctx, "GET", `${CRM}/pulse?${params}`), (r) => {
|
|
3636
|
+
const rank = { high: 0, watch: 1, ok: 2 };
|
|
3637
|
+
const rows = Object.entries(r.pulses).sort(
|
|
3638
|
+
([, a], [, b]) =>
|
|
3639
|
+
rank[a.churnRisk.level] - rank[b.churnRisk.level] || b.churnRisk.score - a.churnRisk.score
|
|
3640
|
+
);
|
|
3641
|
+
const asOf = r.snapshot
|
|
3642
|
+
? `(as of ${r.snapshot.computedAt}${r.snapshot.stale ? ", STALE: the refresh is failing" : ""})\n`
|
|
3643
|
+
: "";
|
|
3644
|
+
return (
|
|
3645
|
+
asOf +
|
|
3646
|
+
(rows.length
|
|
3647
|
+
? rows
|
|
3648
|
+
.map(
|
|
3649
|
+
([id, p]) =>
|
|
3650
|
+
`${p.churnRisk.level.padEnd(5)} ${p.phantomPostsLast30d} phantom posts in 30d${p.clicksTrend === null ? "" : ` · clicks ${p.clicksTrend >= 0 ? "+" : ""}${Math.round(p.clicksTrend * 100)}%`}${p.slackWaiting ? ` · ${p.slackWaiting} waiting in Slack` : ""}${p.nextProduct ? ` · next: ${p.nextProduct}` : ""}${p.churnRisk.reasons.length ? ` ${p.churnRisk.reasons.join("; ")}` : ""} ${id}`
|
|
3651
|
+
)
|
|
3652
|
+
.join("\n")
|
|
3653
|
+
: "(no accounts in that scope)")
|
|
3654
|
+
);
|
|
3613
3655
|
});
|
|
3614
3656
|
}
|
|
3615
3657
|
case "case": {
|
|
@@ -3690,7 +3732,7 @@ async function cmdAdminCrm(ctx) {
|
|
|
3690
3732
|
}
|
|
3691
3733
|
default:
|
|
3692
3734
|
throw new CliError(
|
|
3693
|
-
"Usage: admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|revenue|reach-outs|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|track-record|at-risk|expansion-signals|collections|delivery-flags|case|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
3735
|
+
"Usage: admin crm import|capture|outreach|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|revenue|reach-outs|record-churn|draft-churn-reason|undo-churn|outcomes|record-outcome|renewals|track-record|weekly-note|at-risk|expansion-signals|collections|delivery-flags|pulse|case|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (see --help)"
|
|
3694
3736
|
);
|
|
3695
3737
|
}
|
|
3696
3738
|
}
|
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", "linear"] },
|
|
180
|
+
action: { flag: "action", enum: ["build", "build_pr", "build_merge", "slack", "linear", "handoff"] },
|
|
181
181
|
// --with-preview: the PR opens straight away and a preview of the fix stays up next to it.
|
|
182
182
|
with_preview: { flag: "with-preview", type: "bool" },
|
|
183
183
|
},
|
|
@@ -557,9 +557,19 @@ async function siteFix(ctx) {
|
|
|
557
557
|
const result = await client.callTool("fix_site_observations", args);
|
|
558
558
|
ok(ctx, result.message);
|
|
559
559
|
printResult(io, flags, result, (r) => {
|
|
560
|
-
|
|
560
|
+
// Handed off (action handoff, or slack / linear with no repo): no run; the prompt itself, or where it went.
|
|
561
|
+
const lines = r.run
|
|
562
|
+
? [`${r.run.id} ${r.run.repo} [${r.run.status}]${r.reused ? " (already running)" : ""}`]
|
|
563
|
+
: r.handoff?.kind === "slack"
|
|
564
|
+
? [`sent to Slack${r.handoff.permalink ? ` ${r.handoff.permalink}` : ""}`]
|
|
565
|
+
: r.handoff?.kind === "linear"
|
|
566
|
+
? [
|
|
567
|
+
`filed in Linear${r.handoff.issue ? ` ${r.handoff.issue.identifier} ${r.handoff.issue.url}` : ""}`,
|
|
568
|
+
]
|
|
569
|
+
: [];
|
|
561
570
|
for (const id of r.fixing) lines.push(` fixing ${id}`);
|
|
562
571
|
for (const s of r.skipped) lines.push(` skipped ${s.id} ${s.reason}`);
|
|
572
|
+
if (r.handoff?.kind === "prompt") lines.push("", r.handoff.instructions);
|
|
563
573
|
return lines.join("\n");
|
|
564
574
|
});
|
|
565
575
|
return 0;
|