@letterstory/cli 0.75.0 → 0.76.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/README.md CHANGED
@@ -749,6 +749,14 @@ AI visibility and page access — the Measure tabs' reads, for scripts and agent
749
749
  # search, actions, targets — `ai` is the slow one on a large org.
750
750
  letterstory insights summary [--period 14d|30d|90d] [--part <block>]...
751
751
 
752
+ # GEO → Competitors: every tracked brand's share of mentions (one per brand per
753
+ # answer) on the window, whether each is moving (the same question on the same
754
+ # engine in both windows — the only up/down read), the question × engine cells
755
+ # behind it, the line per engine, each question's top rival, and every dated
756
+ # change under the number. --period sets the windows: 14d → last 2 complete
757
+ # weeks vs the 2 before, 30d → 4 vs 4, 90d → 13 vs 13.
758
+ letterstory insights competitors [--period 14d|30d|90d]
759
+
752
760
  # The AI Visibility Bank: which questions named you, cited your pages, or both (a
753
761
  # breakthrough). All time unless --period; --source picks the phantom network
754
762
  # (default), your marketing site, or the whole portfolio; --phantom narrows to
package/lib/cli.mjs CHANGED
@@ -56,7 +56,7 @@ import {
56
56
  } from "./commands.mjs";
57
57
 
58
58
  // Keep in sync with cli/package.json.
59
- export const VERSION = "0.75.0";
59
+ export const VERSION = "0.76.1";
60
60
 
61
61
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
62
62
  // can't accidentally swallow the id as --json's value.
@@ -582,8 +582,12 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
582
582
  ledger entry. Written by \`npm run lab --
583
583
  publish\` in the kernels repo.
584
584
  admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month]
585
+ | reconcile [--from D --to D] | pnl [--range day|week|month|year | --from D --to D]
586
+ | reference add --vendor V [--key K] --metric billed_usd --value N --from D [--to D] --ref "…"
587
+ | stripe-sync --from D --to D
585
588
  | entries [--from D] [--to D] [--vendor V] [--area A] [--source invoice|csv_import|api|manual]
586
589
  | add --vendor V --amount N --from D [--to D] --area A --category C [--basis billed|estimated] …
590
+ | import <file.csv> [--vendor Concentrate] [--area A] [--ledger-key K] [--dry-run]
587
591
  | import <file.csv> [--vendor Concentrate] [--area A] [--keep-ledger-key] [--dry-run]
588
592
  | usage <events.json> per-call costs as kernels/lettertrace push them
589
593
  | remove <entry-id> | services | service <slug> [--owner …] [--monthly N|none] [--renewal D]
@@ -918,6 +922,7 @@ Mail:
918
922
 
919
923
  Insights:
920
924
  insights summary [--period …] [--part <block>]... Measure Overview: AI named/cited rates, share of voice, search, targets
925
+ insights competitors [--period 14d|30d|90d] GEO → Competitors: share of mentions per brand, is it moving, by engine/question
921
926
  insights site [--period 14d|30d|90d] [--collection <uuid>]
922
927
  insights post <article-id> [--period 14d|30d|90d]
923
928
  insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
@@ -13,7 +13,7 @@
13
13
  // The old names — evals, eval-add, eval-set, proposals, propose-eval, capture-eval, reject-eval — still work.)
14
14
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
15
15
  // admin crm import|capture|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
16
- // admin spend summary|entries|add|import|usage|remove|services|service … (spend:read / spend:write)
16
+ // admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
17
17
  // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
18
18
  // admin replay --org <id> [<response-id> | --target <id>] [--surface …] (fleet:replay)
19
19
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
@@ -3019,7 +3019,7 @@ const SPEND_CATEGORIES = [
3019
3019
  "devtools",
3020
3020
  "other",
3021
3021
  ];
3022
- const SPEND_BASES = ["billed", "estimated"];
3022
+ const SPEND_BASES = ["billed", "reported", "estimated"];
3023
3023
  const SPEND_SOURCES = ["invoice", "csv_import", "api", "manual"];
3024
3024
 
3025
3025
  function usd(n) {
@@ -3088,6 +3088,8 @@ function spendEntryFromFlags(flags) {
3088
3088
  source_ref: flagStr(flags.ref),
3089
3089
  note: flagStr(flags.note),
3090
3090
  external_id: flagStr(flags["external-id"]),
3091
+ byok: flags.byok === undefined ? undefined : flagBool(flags.byok),
3092
+ ledger_scope: flags["ledger-scope"] === undefined ? undefined : flagBool(flags["ledger-scope"]),
3091
3093
  });
3092
3094
  }
3093
3095
 
@@ -3140,7 +3142,7 @@ async function cmdAdminSpend(ctx) {
3140
3142
  const path = positionals[1];
3141
3143
  if (!path)
3142
3144
  throw new CliError(
3143
- 'Usage: admin spend import <file.csv> [--vendor Concentrate] [--area kernels] [--category ai_model] [--basis billed|estimated] [--ref "…"] [--keep-ledger-key] [--dry-run]'
3145
+ 'Usage: admin spend import <file.csv> [--vendor Concentrate] [--area kernels] [--category ai_model] [--basis billed|estimated] [--ref "…"] [--ledger-key \"Main Concentrate Key\"] [--dry-run]'
3144
3146
  );
3145
3147
  const body = compact({
3146
3148
  csv: readFileSync(path, "utf8"),
@@ -3149,7 +3151,7 @@ async function cmdAdminSpend(ctx) {
3149
3151
  category: oneOf(flags, "category", SPEND_CATEGORIES),
3150
3152
  basis: oneOf(flags, "basis", SPEND_BASES),
3151
3153
  source_ref: flagStr(flags.ref) ?? `CSV import: ${path.split("/").pop()}`,
3152
- exclude_ledger_key: flagBool(flags["keep-ledger-key"]) ? null : undefined,
3154
+ ledger_key: flagStr(flags["ledger-key"]),
3153
3155
  dry_run: flagBool(flags["dry-run"]) || undefined,
3154
3156
  });
3155
3157
  const result = await staffPost(ctx, `${SPEND}/import`, body);
@@ -3157,8 +3159,8 @@ async function cmdAdminSpend(ctx) {
3157
3159
  ctx,
3158
3160
  `${result.used} of ${result.rows} rows → ${result.entries.length} entries, ${usd(result.total)}` +
3159
3161
  (result.days ? ` (${result.days.from} → ${result.days.to})` : "") +
3160
- (result.excludedLedgerRows
3161
- ? `; ${result.excludedLedgerRows} BYOK rows on the ledger's key dropped`
3162
+ (result.ledgerScopeRows
3163
+ ? `; ${result.ledgerScopeRows} rows on the ledger's own key kept as ledger-scope (reconciled, not added)`
3162
3164
  : "") +
3163
3165
  (flagBool(flags["dry-run"]) ? " (dry run, nothing written)" : "")
3164
3166
  );
@@ -3211,6 +3213,85 @@ async function cmdAdminSpend(ctx) {
3211
3213
  printResult(io, flags, result);
3212
3214
  return 0;
3213
3215
  }
3216
+ case "reconcile": {
3217
+ const params = new URLSearchParams(compact({ from: flagStr(flags.from), to: flagStr(flags.to) }));
3218
+ const result = await staffRequest(ctx, "GET", `${SPEND}/reconcile${params.size ? `?${params}` : ""}`);
3219
+ printResult(io, flags, result, (r) =>
3220
+ [
3221
+ `${r.window.from} → ${r.window.to}: ${r.rows.filter((x) => x.flag).length} of ${r.rows.length} checks flagged`,
3222
+ ...r.rows.map(
3223
+ (x) =>
3224
+ `${x.flag ? "⚑" : " "} ${x.label.slice(0, 44).padEnd(44)} ours ${usd(x.ours.value).padStart(10)} theirs ${usd(x.theirs.value).padStart(10)} ${x.diffPct === null ? "" : `${x.diffPct > 0 ? "+" : ""}${x.diffPct}%`}`
3225
+ ),
3226
+ ...r.notes.map((n) => ` · ${n}`),
3227
+ ].join("\n")
3228
+ );
3229
+ return 0;
3230
+ }
3231
+ case "pnl": {
3232
+ const params = new URLSearchParams(
3233
+ compact({
3234
+ range: oneOf(flags, "range", SPEND_RANGES),
3235
+ from: flagStr(flags.from),
3236
+ to: flagStr(flags.to),
3237
+ })
3238
+ );
3239
+ const result = await staffRequest(ctx, "GET", `${SPEND}/pnl${params.size ? `?${params}` : ""}`);
3240
+ printResult(io, flags, result, (p) =>
3241
+ [
3242
+ `${p.range.from} → ${p.range.to}: revenue ${usd(p.totals.revenue)} (${p.revenueSource}) · cost ${usd(p.totals.cost.total)} · margin ${usd(p.totals.margin)}${p.totals.marginPct === null ? "" : ` (${p.totals.marginPct}%)`}`,
3243
+ ` MRR ${usd(p.totals.mrr)} · invoiced ${usd(p.totals.invoiced)} · owed ${usd(p.totals.owed)} · Stripe fees ${usd(p.totals.stripeFees)}`,
3244
+ "",
3245
+ ...p.lines.map(
3246
+ (l) =>
3247
+ ` ${l.label.slice(0, 46).padEnd(46)} rev ${usd(l.revenue).padStart(9)} cost ${usd(l.cost).padStart(9)} margin ${usd(l.margin).padStart(10)}`
3248
+ ),
3249
+ ].join("\n")
3250
+ );
3251
+ return 0;
3252
+ }
3253
+ case "reference": {
3254
+ const vendor = flagStr(flags.vendor);
3255
+ const metric = flagStr(flags.metric);
3256
+ const value = flagNum(flags.value);
3257
+ const from = flagStr(flags.from);
3258
+ const to = flagStr(flags.to) ?? from;
3259
+ const ref = flagStr(flags.ref);
3260
+ if (positionals[1] !== "add" || !vendor || !metric || value === undefined || !from || !ref) {
3261
+ throw new CliError(
3262
+ 'Usage: admin spend reference add --vendor Concentrate [--key "Writing Kernels v2"] --metric billed_usd --value 17324.24 --from 2026-09-10 --to 2026-10-09 --ref "Concentrate dashboard 2026-10-09"'
3263
+ );
3264
+ }
3265
+ const result = await staffPost(
3266
+ ctx,
3267
+ `${SPEND}/references`,
3268
+ compact({
3269
+ vendor,
3270
+ key_label: flagStr(flags.key),
3271
+ metric,
3272
+ value,
3273
+ period_start: from,
3274
+ period_end: to,
3275
+ source_ref: ref,
3276
+ note: flagStr(flags.note),
3277
+ })
3278
+ );
3279
+ ok(ctx, `Recorded ${metric} ${value} for ${vendor}${flags.key ? ` (${flagStr(flags.key)})` : ""}.`);
3280
+ printResult(io, flags, result);
3281
+ return 0;
3282
+ }
3283
+ case "stripe-sync": {
3284
+ const from = flagStr(flags.from);
3285
+ const to = flagStr(flags.to);
3286
+ if (!from || !to) throw new CliError("Usage: admin spend stripe-sync --from 2026-01-01 --to 2026-10-09");
3287
+ const result = await staffPost(ctx, `${SPEND}/stripe-sync`, { from, to });
3288
+ ok(
3289
+ ctx,
3290
+ `Stripe ${result.status}: ${result.transactions ?? 0} balance transactions, fees filed for ${result.feeDays ?? 0} days.`
3291
+ );
3292
+ printResult(io, flags, result);
3293
+ return 0;
3294
+ }
3214
3295
  case "services": {
3215
3296
  const result = await staffRequest(ctx, "GET", `${SPEND}/services`);
3216
3297
  printResult(io, flags, result, formatSpendServices);
@@ -3240,7 +3321,7 @@ async function cmdAdminSpend(ctx) {
3240
3321
  }
3241
3322
  default:
3242
3323
  throw new CliError(
3243
- "Usage: admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month] | entries [--from D] [--to D] [--vendor V] [--area A] [--source S] | add --vendor V --amount N --from D … | import <file.csv> … | usage <events.json> | remove <entry-id> | services | service <slug> --owner … --monthly …"
3324
+ "Usage: admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month] | reconcile [--from D --to D] | pnl [--range …] | reference add … | stripe-sync --from D --to D | entries [--from D] [--to D] [--vendor V] [--area A] [--source S] | add --vendor V --amount N --from D … | import <file.csv> … | usage <events.json> | remove <entry-id> | services | service <slug> --owner … --monthly …"
3244
3325
  );
3245
3326
  }
3246
3327
  }
@@ -1,7 +1,8 @@
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
- // insights.ts); plus Measure's Overview (`summary`), AI Visibility Bank (`bank`)
4
- // and phantom Page Access (`access`) — the same reads the web app's Measure tabs render.
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.
5
6
 
6
7
  import { CliError } from "../client.mjs";
7
8
  import { flagStr, flagNum, requirePositional, printResult, compact, argsFromFlags } from "./shared.mjs";
@@ -23,6 +24,9 @@ export const ARGS = {
23
24
  period: { flag: "period", enum: PERIODS },
24
25
  parts: { flag: "part", type: "list" },
25
26
  },
27
+ get_share_of_voice_detail: {
28
+ period: { flag: "period", enum: PERIODS },
29
+ },
26
30
  };
27
31
 
28
32
  export async function cmdInsights(ctx) {
@@ -41,9 +45,11 @@ export async function cmdInsights(ctx) {
41
45
  return call(rest, "get_page_access");
42
46
  case "summary":
43
47
  return call(rest, "get_measure_summary");
48
+ case "competitors":
49
+ return call(rest, "get_share_of_voice_detail");
44
50
  default:
45
51
  throw new CliError(
46
- `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, site, post, top, bank, access`
52
+ `Unknown insights subcommand: ${sub ?? "(none)"}. Try: summary, competitors, site, post, top, bank, access`
47
53
  );
48
54
  }
49
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.75.0",
3
+ "version": "0.76.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",