@letterstory/cli 0.129.0 → 0.130.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/README.md CHANGED
@@ -118,6 +118,8 @@ letterstory phantom config <phantom-id> --theme commons # patch the sit
118
118
  letterstory phantom config <phantom-id> --collection-config '{"cadence":{"posts_per_week":4}}'
119
119
  letterstory phantom config <phantom-id> --configure --dry-run # what re-configuring would decide, applying nothing
120
120
  letterstory phantom rebuild <phantom-id> # after publishing outside a collection run
121
+ letterstory phantom pause <phantom-id> # stop new posts and probing; the site stays live (resume to undo)
122
+ letterstory phantom resume <phantom-id>
121
123
  letterstory phantom diagnostics <phantom-id> # why is my site empty?
122
124
  letterstory phantom balance # sites live / granted, and the cadence cap
123
125
  letterstory phantom spread-looks [--apply] # a different look per site; dry run unless --apply
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.129.0";
65
+ export const VERSION = "0.130.0";
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
@@ -447,6 +447,7 @@ Reviewers:
447
447
  reviewers assigned Posts waiting on your review, across letterbases
448
448
  automations list --collection <id> What a letterbase writes, how often, and why
449
449
  automations show <automation-id> --collection <id> What it covers, its queued posts, its next ideas
450
+ automations ideas --collection <id> The ideas waiting, grouped by the automation that owns each
450
451
  automations suggest --collection <id> What it could add: topics, series formats, signals
451
452
  automations preview topic|series --collection <id> [--name <name>|--topic <id>] [--brief <text>]
452
453
  What it would write, before adding it
@@ -925,7 +926,8 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
925
926
  | track-record [--since YYYY-MM-DD] | at-risk | expansion-signals
926
927
  | weekly-note the Monday retention note for the last full week
927
928
  | collections [--org-ids a,b | --account-stages won,active]
928
- | delivery-flags [--org-ids a,b | --account-stages won,active] [--include collections]
929
+ | delivery-flags [--org-ids a,b | --account-stages won,active] [--include collections] [--since 2026-10-19T20:00:00Z]
930
+ | pulse [--ids a,b] [--since 2026-10-19T20:00:00Z] how each client is doing, worst churn risk first
929
931
  | case <account-id> [--outcome <outcome-id>]
930
932
  | onboard <account-id> --domain acme.com [--name "Acme"] [--site "<direction>"]… [--sites N]
931
933
  [--slack a@acme.com,b@acme.com] [--invite a@acme.com[:admin]]… [--no-checklist]
@@ -1304,6 +1306,9 @@ Mail:
1304
1306
 
1305
1307
  Insights:
1306
1308
  insights summary [--period …] [--part <block>]... Measure Overview: AI named/cited rates, share of voice, search, targets
1309
+ insights reads [--period …] [--site all|own|phantoms|<key>]... [--path <path>] [--engine <operator>]
1310
+ Measure → Reads: AI engines and people reading your sites, counted one way
1311
+ insights questions [--period 14d|30d|90d] Measure → Questions: every question we ask, named / share / change / cited
1307
1312
  insights competitors [--period 14d|30d|90d] GEO → Competitors: share of mentions per brand, is it moving, by engine/question
1308
1313
  insights question <key> [--period 14d|30d|90d] [--filter all|named_you|rival_not_you|no_one] [--limit n]
1309
1314
  One By-question row: the wordings asked and the AI answers
@@ -1315,6 +1320,7 @@ Insights:
1315
1320
  AI Visibility Bank: named / cited / breakthrough evidence (all time by default)
1316
1321
  insights access [--period 14d|30d|90d] [--collection <uuid>]
1317
1322
  Page access: who's reading your phantom sites
1323
+ insights activity [--phantom <uuid>] [--limit <n>] What hit your phantom sites in the last 7 days, newest first
1318
1324
 
1319
1325
  The Site (one model of your whole marketing site, keyed by host and path):
1320
1326
  site list Mapped hosts, and hosts you already have elsewhere
@@ -1336,6 +1342,8 @@ The Site (one model of your whole marketing site, keyed by host and path):
1336
1342
  site propose <host> [path] --tool <tool> --input '<json>'|--input-file <p> --label <text> [--why <text>]
1337
1343
  [--observation <id>]... Propose a registry tool call; input is validated now
1338
1344
  site decide <proposal-id> --decision accept|dismiss [--reason <why>]
1345
+ site fix <host> <observation-id>... [--action build|build_pr|build_merge|slack|linear|handoff] [--with-preview]
1346
+ One change for N observations; handoff prints the prompt, slack/linear need no repo
1339
1347
  site competitors [--since <iso> | --days <n>] [--limit <n>] Tracked competitors' sites: new and removed pages, by folder
1340
1348
  site watch-competitors [domain] [--limit <n>] Map competitors' sitemaps (the next few unmapped, or one now)
1341
1349
  site highlight <domain> [--off] Highlight a competitor; its changes lead the top changes
@@ -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|weekly-note|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)
@@ -3596,27 +3596,62 @@ async function cmdAdminCrm(ctx) {
3596
3596
  );
3597
3597
  }
3598
3598
  case "delivery-flags": {
3599
- // 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>]
3600
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.
3601
3602
  const params = new URLSearchParams(
3602
3603
  compact({
3603
3604
  org_ids: flagStr(flags["org-ids"]),
3604
3605
  account_stages: flagStr(flags["account-stages"]),
3605
3606
  include: flagStr(flags.include),
3607
+ since: flagStr(flags.since),
3606
3608
  })
3607
3609
  );
3608
3610
  return out(await staffRequest(ctx, "GET", `${CRM}/delivery-flags?${params}`), (r) => {
3609
3611
  if (!r.accounts)
3610
3612
  return "Billing cannot say who pays yet, so there are no flags (collections are in --json).";
3611
3613
  const flagged = r.accounts.filter((a) => a.findings.length);
3612
- return flagged.length
3613
- ? flagged
3614
- .map(
3615
- (a) =>
3616
- `${a.owedDelivery ? "owed " : a.notPaying ? "unpaid" : " "} ${a.orgName} ${a.findings.map((f) => f.message).join("; ")} ${a.orgId}`
3617
- )
3618
- .join("\n")
3619
- : `(${r.accounts.length} org${r.accounts.length === 1 ? "" : "s"}, nothing flagged)`;
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
+ );
3620
3655
  });
3621
3656
  }
3622
3657
  case "case": {
@@ -3697,7 +3732,7 @@ async function cmdAdminCrm(ctx) {
3697
3732
  }
3698
3733
  default:
3699
3734
  throw new CliError(
3700
- "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|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)"
3701
3736
  );
3702
3737
  }
3703
3738
  }
@@ -1,5 +1,5 @@
1
1
  // `automations` — a letterbase's automations (docs/architecture/automations.md): what it writes,
2
- // how often, and why. list / show / suggest / preview / more / add / set / dismiss / remove, through the same tools the app uses.
2
+ // how often, and why. list / show / ideas / suggest / preview / more / add / set / dismiss / remove, through the same tools the app uses.
3
3
  // Same flag/positional/io conventions as posts.mjs — no commander, no color.
4
4
 
5
5
  import { CliError } from "../client.mjs";
@@ -10,6 +10,9 @@ export const ARGS = {
10
10
  list_automations: {
11
11
  collection_id: { flag: "collection", required: true },
12
12
  },
13
+ list_automation_ideas: {
14
+ collection_id: { flag: "collection", required: true },
15
+ },
13
16
  suggest_automations: {
14
17
  collection_id: { flag: "collection", required: true },
15
18
  },
@@ -74,6 +77,8 @@ export async function cmdAutomations(ctx) {
74
77
  return run(rest, "list_automations");
75
78
  case "show":
76
79
  return run(rest, "get_automation");
80
+ case "ideas":
81
+ return run(rest, "list_automation_ideas");
77
82
  case "suggest":
78
83
  return run(rest, "suggest_automations");
79
84
  case "preview":
@@ -92,7 +97,7 @@ export async function cmdAutomations(ctx) {
92
97
  return run(rest, "delete_automation", "Removed.");
93
98
  default:
94
99
  throw new CliError(
95
- `Unknown automations subcommand: ${sub ?? "(none)"}. Try: list, show, suggest, preview, more, add, set, dismiss, remove`
100
+ `Unknown automations subcommand: ${sub ?? "(none)"}. Try: list, show, ideas, suggest, preview, more, add, set, dismiss, remove`
96
101
  );
97
102
  }
98
103
  }
@@ -1,8 +1,9 @@
1
1
  // `insights` — Search Console impressions/clicks across the phantom network, at the
2
2
  // network, single-post, and top-posts levels (ported from phantomstory-cli's
3
3
  // insights.ts); plus Measure's Overview (`summary`), GEO → Competitors
4
- // (`competitors`), AI Visibility Bank (`bank`) and phantom Page Access (`access`)
5
- // — the same reads the web app's Measure tabs render.
4
+ // (`competitors`), AI Visibility Bank (`bank`) and phantom Page Access (`access`);
5
+ // Measure → Reads (`reads`) and Questions (`questions`), and the live feed of what
6
+ // is reading the phantom sites (`activity`) — the same reads the web app renders.
6
7
 
7
8
  import { CliError } from "../client.mjs";
8
9
  import { flagStr, flagNum, requirePositional, printResult, compact, argsFromFlags } from "./shared.mjs";
@@ -28,6 +29,19 @@ export const ARGS = {
28
29
  period: { flag: "period", enum: PERIODS },
29
30
  },
30
31
  get_brand_detection: {},
32
+ get_reads: {
33
+ period: { flag: "period", enum: PERIODS },
34
+ sites: { flag: "site", type: "list" },
35
+ path: { flag: "path" },
36
+ engine: { flag: "engine" },
37
+ },
38
+ list_measure_questions: {
39
+ period: { flag: "period", enum: PERIODS },
40
+ },
41
+ get_recent_activity: {
42
+ phantom_id: { flag: "phantom" },
43
+ limit: { flag: "limit", type: "num" },
44
+ },
31
45
  get_share_of_voice_question: {
32
46
  question_key: { positional: 0, label: "question-key", required: true },
33
47
  period: { flag: "period", enum: PERIODS },
@@ -58,9 +72,15 @@ export async function cmdInsights(ctx) {
58
72
  return call(rest, "get_share_of_voice_question");
59
73
  case "detection":
60
74
  return call(rest, "get_brand_detection");
75
+ case "reads":
76
+ return call(rest, "get_reads");
77
+ case "questions":
78
+ return call(rest, "list_measure_questions");
79
+ case "activity":
80
+ return call(rest, "get_recent_activity");
61
81
  default:
62
82
  throw new CliError(
63
- `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, competitors, question, detection, site, post, top, bank, access`
83
+ `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, reads, questions, competitors, question, detection, activity, site, post, top, bank, access`
64
84
  );
65
85
  }
66
86
  }
@@ -111,6 +111,11 @@ export const ARGS = {
111
111
  },
112
112
  delete_phantom: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
113
113
  rebuild_phantom: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
114
+ // `phantom pause <id>` / `phantom resume <id>` — the verb sets `paused`.
115
+ set_phantom_paused: {
116
+ phantom_id: { positional: 0, label: "phantom-id", required: true },
117
+ paused: { value: (flags, positionals, extra) => extra.paused },
118
+ },
114
119
  get_phantom_diagnostics: { phantom_id: { positional: 0, label: "phantom-id", required: true } },
115
120
  get_phantom_balance: {},
116
121
  spread_phantom_looks: { apply: { flag: "apply", type: "bool" } },
@@ -154,6 +159,9 @@ export async function cmdPhantom(ctx) {
154
159
  return call(rest, "delete_phantom");
155
160
  case "rebuild":
156
161
  return call(rest, "rebuild_phantom");
162
+ case "pause":
163
+ case "resume":
164
+ return phantomPause(rest, sub === "pause");
157
165
  case "diagnostics":
158
166
  case "diag":
159
167
  return call(rest, "get_phantom_diagnostics");
@@ -165,11 +173,17 @@ export async function cmdPhantom(ctx) {
165
173
  return phantomDomain(rest);
166
174
  default:
167
175
  throw new CliError(
168
- `Unknown phantom subcommand: ${sub ?? "(none)"}. Try: list, get, new, config, visitors, delete, rebuild, diagnostics, balance, spread-looks, domain`
176
+ `Unknown phantom subcommand: ${sub ?? "(none)"}. Try: list, get, new, config, visitors, delete, rebuild, pause, resume, diagnostics, balance, spread-looks, domain`
169
177
  );
170
178
  }
171
179
  }
172
180
 
181
+ async function phantomPause(ctx, paused) {
182
+ const args = argsFromFlags(ARGS.set_phantom_paused, ctx.flags, ctx.positionals, { paused });
183
+ printResult(ctx.io, ctx.flags, await ctx.client.callTool("set_phantom_paused", args));
184
+ return 0;
185
+ }
186
+
173
187
  async function call(ctx, tool) {
174
188
  const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
175
189
  printResult(ctx.io, ctx.flags, result);
@@ -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
- const lines = [`${r.run.id} ${r.run.repo} [${r.run.status}]${r.reused ? " (already running)" : ""}`];
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.129.0",
3
+ "version": "0.130.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",