@letterstory/cli 0.126.1 → 0.128.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 CHANGED
@@ -62,12 +62,13 @@ import {
62
62
  } from "./commands.mjs";
63
63
 
64
64
  // Keep in sync with cli/package.json.
65
- export const VERSION = "0.126.1";
65
+ export const VERSION = "0.128.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
69
69
  // `negate` spelling) in a command's ARGS map must be listed; boolean-flags.test.ts enforces it.
70
70
  export const BOOLEAN_FLAGS = new Set([
71
+ "remap",
71
72
  "json",
72
73
  // admin models runs: only the starred ones.
73
74
  "starred",
@@ -80,6 +81,11 @@ export const BOOLEAN_FLAGS = new Set([
80
81
  "stealth",
81
82
  "disclosure",
82
83
  "no-disclosure",
84
+ "no-cta",
85
+ "remember",
86
+ "no-remember",
87
+ // enclosures print: make a new one even for people with a recent print.
88
+ "redo",
83
89
  // site tree: include children a fold-by-default family would hide; items over the whole subtree.
84
90
  "unfold",
85
91
  "subtree",
@@ -280,6 +286,8 @@ export const BOOLEAN_FLAGS = new Set([
280
286
  "not-reached",
281
287
  "reached",
282
288
  "todo",
289
+ // admin spend margin: list nested work (never billed twice) beside its parent.
290
+ "nested",
283
291
  ]);
284
292
 
285
293
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -358,7 +366,7 @@ Collections (the content engine as a primitive — one door, 'collection run'):
358
366
  Print the ONE FILE (identity, content, cadence,
359
367
  publishing, targets, measurement) — or PATCH it
360
368
  collection run --collection <id> [--topic <t> | --spoke <topic-id> | --headline <suggestion-id>]
361
- [--source <url> …] [--must-include <t> …] [--depth barebones|fleshed_out]
369
+ [--source <url> …] [--notes <text>] [--must-include <t> …] [--depth barebones|fleshed_out]
362
370
  [--kernel <id>] [--no-verify] [--shred] [--mention off|subtle|woven]
363
371
  [--approval autopilot|review_window|manual] [--no-publish] [--no-rebuild]
364
372
  [--at <iso-datetime>] [--no-wait]
@@ -369,7 +377,7 @@ Collections (the content engine as a primitive — one door, 'collection run'):
369
377
  collection queue <suggestion-id> Queue a headline, as the app's queue button does: the
370
378
  next slot of the schedule when the Planner is on,
371
379
  else a draft to write by hand
372
- collection add "<headline>" --collection <id> [--queue]
380
+ collection add "<headline>" --collection <id> [--notes <text>] [--queue]
373
381
  A headline you wrote: joins Up next, or with --queue
374
382
  is queued at once (the next slot, or a draft)
375
383
  collection headline <suggestion-id> [--dismiss | --later | --restore] [--headline "…"]
@@ -439,9 +447,16 @@ Reviewers:
439
447
  reviewers assigned Posts waiting on your review, across letterbases
440
448
  automations list --collection <id> What a letterbase writes, how often, and why
441
449
  automations show <automation-id> --collection <id> What it covers, its queued posts, its next ideas
450
+ automations suggest --collection <id> What it could add: topics, series formats, signals
451
+ automations preview topic|series --collection <id> [--name <name>|--topic <id>] [--brief <text>]
452
+ What it would write, before adding it
453
+ automations more --collection <id> New topics it doesn't cover yet
454
+ automations dismiss <topic-id> --collection <id> Set one subject aside
442
455
  automations add topic|series|signal|best --collection <id> [--topic <id>] [--goal <target-id>]
443
- [--seer <id>] [--pace <n>] Add one (refused if it says the same as another)
456
+ [--seer <id>] [--pace <n>] [--brief <text>] [--new-topic <name>] [--new-series <name>]
457
+ Add one (refused if it says the same as another)
444
458
  automations set <automation-id> --collection <id> [--pace <n>] [--pause | --resume] [--goal <id>]
459
+ [--brief <text>] [--remap]
445
460
  automations remove <automation-id> --collection <id>
446
461
  reviewers invite <article-id> --email <e> [--name <n>]
447
462
  Invite a guest to review one post
@@ -539,6 +554,12 @@ Enclosures (pictures, clips and sentences made for each prospect; each output is
539
554
  [--login <app-connection-id>] [--tool run_site_audit…] [--skip-when <sentence>]
540
555
  enclosures update <enclosure-id> [same flags] Outputs are fixed once a version is in use
541
556
  enclosures prints <enclosure-id> [--status <s>] Its current prints, newest first
557
+ enclosures starters What an enclosure can begin from (each with a version 1)
558
+ enclosures approve <enclosure-id> --version <n> Choose the version prints run
559
+ enclosures print <enclosure-id> (--prospect <id>…|--list <id>) [--dry-run] Test (3 people) or print
560
+ enclosures apps list|show <app-id> Your apps: sign-ins enclosures use, with their state
561
+ enclosures apps connect <sign-in-url> [--no-remember] A sign-in link a person opens (no password passes through)
562
+ enclosures apps disconnect <app-id> --yes Remove it here and at the browser provider
542
563
 
543
564
  Offerings (context layered org → offering → collection; a layer sets only what it overrides):
544
565
  offerings list The org's offerings (things you'd name when recommending them)
@@ -862,7 +883,7 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
862
883
  session (a trial per arm), linked to its
863
884
  ledger entry. Written by \`npm run lab --
864
885
  publish\` in the kernels repo.
865
- admin models runs [--model M] [--starred] | run <id> | star|unstar <id> | scoreboard --model M
886
+ admin models runs [--search T] [--model M] [--starred] | run <id> | star|unstar <id> | scoreboard --model M
866
887
  The model hub's saved runs and blind arena (models.letter.dev)
867
888
  admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month]
868
889
  | reconcile [--from D --to D] | pnl [--range day|week|month|year | --from D --to D]
@@ -1373,6 +1394,8 @@ Visits (Visitors: AI agents reading your site, and AI answers sending people to
1373
1394
  Pages one agent read that the other didn't
1374
1395
  visits export [--since 30d] [--format json|csv] [--table agent_visits|citations]
1375
1396
  visits sites Your Visitors sites and their install checks
1397
+ visits pages [--since 28d] [--path '/docs/*'] [--agent <id>] [--limit <n>] [--site <id>]
1398
+ People and AI agents per page
1376
1399
 
1377
1400
  Evals (how the pipeline is doing on your posts, and the regression cases captured from them):
1378
1401
  evals scoreboard [--collection <id>] [--days N] Every measure by stage: posts, failing, rate
@@ -15,7 +15,7 @@
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
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)
18
- // admin spend summary|reconcile|pnl|entries|add|import|usage|remove|reference|stripe-sync|services|service … (spend:read / spend:write)
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)
21
21
  // admin recipes list|show|vocab|export|import|save|delete … (recipes:read / recipes:write)
@@ -4005,7 +4005,7 @@ function spendEntryFromFlags(flags) {
4005
4005
 
4006
4006
  const MODELS = "/api/superadmin/models";
4007
4007
  const MODELS_USAGE =
4008
- "Usage: admin models runs [--model ballpoint-1] [--starred] [--limit N] | run <id> | star <id> | unstar <id> | scoreboard --model ballpoint-1";
4008
+ "Usage: admin models runs [--search TEXT] [--model ballpoint-1] [--starred] [--limit N] | run <id> | star <id> | unstar <id> | scoreboard --model ballpoint-1";
4009
4009
 
4010
4010
  /** The model hub's saved runs and blind arena (models.letter.dev), same functions as its pages. */
4011
4011
  async function cmdAdminModels(ctx) {
@@ -4015,6 +4015,7 @@ async function cmdAdminModels(ctx) {
4015
4015
  case "runs": {
4016
4016
  const params = new URLSearchParams(
4017
4017
  compact({
4018
+ q: flagStr(flags.search),
4018
4019
  model: flagStr(flags.model),
4019
4020
  starred: flagBool(flags.starred) ? "true" : undefined,
4020
4021
  limit: flagNum(flags.limit)?.toString(),
@@ -4264,6 +4265,70 @@ async function cmdAdminSpend(ctx) {
4264
4265
  );
4265
4266
  return 0;
4266
4267
  }
4268
+ case "margin": {
4269
+ // Shadow usage metering: usage per SKU, our cost, draft tokens and margin
4270
+ // (GET /api/superadmin/spend/usage-margin → summarizeUsage).
4271
+ const params = new URLSearchParams(
4272
+ compact({
4273
+ from: flagStr(flags.from),
4274
+ to: flagStr(flags.to),
4275
+ orgId: flagStr(flags.org),
4276
+ groupBy: flagStr(flags["group-by"]),
4277
+ includeNested: flagBool(flags.nested) ? "true" : undefined,
4278
+ rateCard: flagStr(flags["rate-card"]),
4279
+ })
4280
+ );
4281
+ const result = await staffRequest(ctx, "GET", `${SPEND}/usage-margin${params.size ? `?${params}` : ""}`);
4282
+ printResult(io, flags, result, (m) =>
4283
+ [
4284
+ `${m.range.from} → ${m.range.to}: ${m.totals.billableEvents} billable of ${m.totals.events} events · ${m.totals.tokens ?? "?"} tokens (${m.rateCard?.version ?? "no card"}) · cost ${usd(m.totals.cost?.totalUsd)} · margin ${m.totals.marginUsd === null ? "?" : usd(m.totals.marginUsd)}`,
4285
+ "",
4286
+ ...m.rows.map(
4287
+ (r) =>
4288
+ ` ${[r.day, r.orgId, r.collectionId, r.customerRef, r.sku].filter(Boolean).join(" ").slice(0, 60).padEnd(60)} ${String(r.billableQuantity).padStart(9)} ${r.unit.padEnd(10)} ${String(r.tokens ?? "?").padStart(9)} tok cost ${usd(r.cost?.totalUsd).padStart(9)}${r.cost?.eventsWithoutCost ? ` (${r.cost.eventsWithoutCost} unpriced)` : ""}`
4289
+ ),
4290
+ ].join("\n")
4291
+ );
4292
+ return 0;
4293
+ }
4294
+ case "pricing": {
4295
+ // What shadow metering says prices should be (GET /api/superadmin/spend/pricing-read
4296
+ // → readPricing). Reads only; nothing changes a price.
4297
+ const params = new URLSearchParams(
4298
+ compact({
4299
+ from: flagStr(flags.from),
4300
+ to: flagStr(flags.to),
4301
+ multiple: flagStr(flags.multiple),
4302
+ tiers: flagStr(flags.tiers),
4303
+ rateCard: flagStr(flags["rate-card"]),
4304
+ })
4305
+ );
4306
+ const result = await staffRequest(ctx, "GET", `${SPEND}/pricing-read${params.size ? `?${params}` : ""}`);
4307
+ const cents = (c) => usd((c ?? 0) / 100);
4308
+ const unit = (n) => (n === null || n === undefined ? "?" : `$${Number(n).toFixed(n < 0.1 ? 4 : 2)}`);
4309
+ printResult(io, flags, result, (p) =>
4310
+ [
4311
+ `${p.range.from} → ${p.range.to} (${p.range.days}d, ${p.range.buckets} month buckets) · card ${p.rateCard.version}${p.rateCard.published ? "" : " (draft)"} · proposal at ${p.multiple}× p75 cost · revenue as of ${p.revenueCapturedOn ?? "no snapshot"}`,
4312
+ "",
4313
+ "Per SKU: unit cost p50 / p75 / p90 · priced of billable events · tokens a unit now → proposed",
4314
+ ...p.skus
4315
+ .filter((s) => s.events > 0)
4316
+ .map(
4317
+ (s) =>
4318
+ ` ${s.sku.padEnd(30)} ${unit(s.unitCostUsd.p50).padStart(8)} ${unit(s.unitCostUsd.p75).padStart(8)} ${unit(s.unitCostUsd.p90).padStart(8)} ${String(s.pricedEvents).padStart(6)}/${String(s.events).padEnd(6)} ${s.pricing === "per_unit" ? `${s.current ?? "?"} → ${s.proposedTokensPerUnit ?? "?"}` : `cost × ${s.current ?? "?"}`}${s.confidence === "measured" ? "" : ` (${s.confidence})`}${s.gaps.unattributed || s.gaps.unpricedCalls || s.gaps.partialOwnCost || s.gaps.withoutCost ? ` [left out: ${s.gaps.unattributed} unattributed, ${s.gaps.unpricedCalls} unpriced calls, ${s.gaps.partialOwnCost} partial own cost, ${s.gaps.withoutCost} no cost]` : ""}`
4319
+ ),
4320
+ "",
4321
+ `Paying clients: ${p.fleet.clients} · monthly tokens p50 ${p.fleet.monthlyTokens.p50 ?? "?"} · p75 ${p.fleet.monthlyTokens.p75 ?? "?"} · p90 ${p.fleet.monthlyTokens.p90 ?? "?"}`,
4322
+ "Per client (child accounts rolled up): revenue/mo · tokens/mo mean, peak · cost/mo · tier · complimentary tokens",
4323
+ ...p.clients.map(
4324
+ (o) =>
4325
+ ` ${`${o.name ?? o.clientId}${o.orgIds.length > 1 ? ` (${o.orgIds.length} orgs)` : ""}`.slice(0, 28).padEnd(28)} ${cents(o.revenueMonthlyCents).padStart(9)} ${String(o.monthlyTokens.mean).padStart(9)} ${String(o.monthlyTokens.peak).padStart(9)} ${usd(o.monthlyCostUsd.mean).padStart(9)} ${o.ratioTier ? `${o.ratioTier}×` : "—"} ${o.complimentaryTokens ?? "—"}${o.flags.length ? ` [${o.flags.join(", ")}]` : ""}`
4326
+ ),
4327
+ ...p.notes.map((n) => `· ${n}`),
4328
+ ].join("\n")
4329
+ );
4330
+ return 0;
4331
+ }
4267
4332
  case "reference": {
4268
4333
  const vendor = flagStr(flags.vendor);
4269
4334
  const metric = flagStr(flags.metric);
@@ -4338,7 +4403,7 @@ async function cmdAdminSpend(ctx) {
4338
4403
  }
4339
4404
  default:
4340
4405
  throw new CliError(
4341
- "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> | usage list --source S --from D --to D | remove <entry-id> | services | service <slug> --owner … --monthly …"
4406
+ "Usage: admin spend summary [--range day|week|month|year | --from D --to D] [--grain day|week|month] | reconcile [--from D --to D] | pnl [--range …] | margin [--from D --to D] [--org <uuid>] [--group-by sku,org,collection,customer_ref,day] [--nested] [--rate-card V] | pricing [--from D --to D] [--multiple 3] [--tiers 1,2,4,8] [--rate-card V] | 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> | usage list --source S --from D --to D | remove <entry-id> | services | service <slug> --owner … --monthly …"
4342
4407
  );
4343
4408
  }
4344
4409
  }
@@ -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 / add / set / remove, through the same tools the app uses.
2
+ // how often, and why. list / show / 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
+ suggest_automations: {
14
+ collection_id: { flag: "collection", required: true },
15
+ },
13
16
  get_automation: {
14
17
  collection_id: { flag: "collection", required: true },
15
18
  automation_id: { positional: 0, label: "automation-id", required: true },
@@ -21,6 +24,13 @@ export const ARGS = {
21
24
  target_id: { flag: "goal" },
22
25
  seer_id: { flag: "seer" },
23
26
  pace_per_week: { flag: "pace", type: "num" },
27
+ brief: { flag: "brief" },
28
+ new_topic: {
29
+ value: (flags) => (typeof flags["new-topic"] === "string" ? { name: flags["new-topic"] } : undefined),
30
+ },
31
+ new_series: {
32
+ value: (flags) => (typeof flags["new-series"] === "string" ? { name: flags["new-series"] } : undefined),
33
+ },
24
34
  },
25
35
  update_automation: {
26
36
  collection_id: { flag: "collection", required: true },
@@ -30,6 +40,24 @@ export const ARGS = {
30
40
  value: (flags) => (flags.pause === true ? true : flags.resume === true ? false : undefined),
31
41
  },
32
42
  target_id: { flag: "goal" },
43
+ brief: { flag: "brief" },
44
+ remap: { value: (flags) => (flags.remap === true ? true : undefined) },
45
+ subjects: { value: () => undefined },
46
+ },
47
+ preview_automation: {
48
+ collection_id: { flag: "collection", required: true },
49
+ kind: { positional: 0, label: "kind", required: true, enum: ["topic", "series"] },
50
+ cluster_id: { flag: "topic" },
51
+ name: { flag: "name" },
52
+ brief: { flag: "brief" },
53
+ items: { flag: "items", type: "list" },
54
+ },
55
+ suggest_more_topics: {
56
+ collection_id: { flag: "collection", required: true },
57
+ },
58
+ dismiss_subject: {
59
+ collection_id: { flag: "collection", required: true },
60
+ topic_id: { positional: 0, label: "topic-id", required: true },
33
61
  },
34
62
  delete_automation: {
35
63
  collection_id: { flag: "collection", required: true },
@@ -46,16 +74,26 @@ export async function cmdAutomations(ctx) {
46
74
  return run(rest, "list_automations");
47
75
  case "show":
48
76
  return run(rest, "get_automation");
77
+ case "suggest":
78
+ return run(rest, "suggest_automations");
79
+ case "preview":
80
+ return run(rest, "preview_automation");
81
+ case "more":
82
+ return run(rest, "suggest_more_topics");
83
+ case "dismiss":
84
+ return run(rest, "dismiss_subject", "Set aside.");
49
85
  case "add":
50
86
  return run(rest, "create_automation", "Added.");
51
87
  case "set":
52
- refuseUnknownFlags(rest, ARGS.update_automation, ["pause", "resume"]);
88
+ refuseUnknownFlags(rest, ARGS.update_automation, ["pause", "resume", "remap"]);
53
89
  return run(rest, "update_automation", "Changed.");
54
90
  case "remove":
55
91
  case "rm":
56
92
  return run(rest, "delete_automation", "Removed.");
57
93
  default:
58
- throw new CliError(`Unknown automations subcommand: ${sub ?? "(none)"}. Try: list, show, add, set, remove`);
94
+ throw new CliError(
95
+ `Unknown automations subcommand: ${sub ?? "(none)"}. Try: list, show, suggest, preview, more, add, set, dismiss, remove`
96
+ );
59
97
  }
60
98
  }
61
99
 
@@ -76,6 +76,7 @@ export const ARGS = {
76
76
  topic_id: { flag: "spoke" },
77
77
  suggestion_id: { flag: "headline" },
78
78
  source_urls: { flag: "source", type: "list" },
79
+ context: { flag: "notes" },
79
80
  must_include: { flag: "must-include", type: "list" },
80
81
  research_depth: { flag: "depth", enum: ["barebones", "fleshed_out"] },
81
82
  kernel_id: { flag: "kernel" },
@@ -93,6 +94,7 @@ export const ARGS = {
93
94
  add_headline: {
94
95
  collection_id: { flag: "collection", required: true },
95
96
  headline: { positional: 0, label: "headline", required: true },
97
+ context: { flag: "notes" },
96
98
  queue: { flag: "queue", type: "bool" },
97
99
  },
98
100
  update_suggestion: {
@@ -302,7 +304,7 @@ async function collectionHeadline(ctx) {
302
304
  return 0;
303
305
  }
304
306
 
305
- // `collection add "<headline>" --collection <id> [--queue]` — the letterbase page's New post: a
307
+ // `collection add "<headline>" --collection <id> [--notes <text>] [--queue]` — the letterbase page's New post: a
306
308
  // headline you wrote joins Up next, or with --queue takes the next slot (a draft, planner off).
307
309
  async function collectionAdd(ctx) {
308
310
  const { client, flags, io } = ctx;
@@ -64,10 +64,19 @@ const fields = {
64
64
  budget_per_person_usd: { flag: "budget", type: "num" },
65
65
  no_fit_rule: "skip-when",
66
66
  disclosure: { flag: "disclosure", type: "bool", negate: "no-disclosure" },
67
+ cta: {
68
+ value: (flags) => {
69
+ if (flags["no-cta"] === true) return null;
70
+ const label = flagStr(flags["cta-label"]);
71
+ const url = flagStr(flags["cta-url"]);
72
+ return label || url ? { label, url } : undefined;
73
+ },
74
+ },
67
75
  };
68
76
 
69
77
  // Flags the computed sources above read (refuseUnknownFlags can't see inside `value`).
70
78
  const FIELD_FLAGS = [
79
+ "starter",
71
80
  "instructions",
72
81
  "instructions-file",
73
82
  "input",
@@ -79,6 +88,9 @@ const FIELD_FLAGS = [
79
88
  "region",
80
89
  "image-model",
81
90
  "tool",
91
+ "cta-label",
92
+ "cta-url",
93
+ "no-cta",
82
94
  ];
83
95
 
84
96
  // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
@@ -90,13 +102,58 @@ export const ARGS = {
90
102
  },
91
103
  create_generator: {
92
104
  key: "key",
105
+ starter: "starter",
93
106
  ...fields,
94
- name: { flag: "name", required: true },
95
107
  },
96
108
  update_generator: {
97
109
  generator_id: { positional: 0, label: "enclosure-id", required: true },
98
110
  ...fields,
99
111
  },
112
+ list_generator_starters: {},
113
+ add_generator_version: {
114
+ generator_id: { positional: 0, label: "enclosure-id", required: true },
115
+ script: { value: (flags) => readTextInput(flags, "script", "script-file") },
116
+ steps: {
117
+ value: (flags) => {
118
+ const v = flagList(flags.step);
119
+ return v.length ? v.map((text, i) => ({ n: i + 1, text })) : undefined;
120
+ },
121
+ },
122
+ allowed_hosts: { flag: "host", type: "list" },
123
+ region: {
124
+ value: (flags) => {
125
+ const v = flagList(flags["region-selector"]);
126
+ return v.length ? { selectors: v } : undefined;
127
+ },
128
+ },
129
+ params_schema: { internal: "set by the app when a person edits steps; not a flag" },
130
+ record_span: { internal: "set by the app when a recording is marked; not a flag" },
131
+ change_summary: "summary",
132
+ },
133
+ approve_generator_version: {
134
+ generator_id: { positional: 0, label: "enclosure-id", required: true },
135
+ version: { flag: "version", type: "num", required: true },
136
+ },
137
+ print_generator: {
138
+ generator_id: { positional: 0, label: "enclosure-id", required: true },
139
+ prospect_ids: { flag: "prospect", type: "list" },
140
+ list_id: "list",
141
+ version: { flag: "version", type: "num" },
142
+ redo: { flag: "redo", type: "bool" },
143
+ dry_run: { flag: "dry-run", type: "bool" },
144
+ },
145
+ list_app_connections: {},
146
+ get_app_connection: {
147
+ connection_id: { positional: 0, label: "app-id", required: true },
148
+ },
149
+ connect_app: {
150
+ login_url: { positional: 0, label: "sign-in-url", required: true },
151
+ name: "name",
152
+ remember_sign_in: { flag: "remember", type: "bool", negate: "no-remember" },
153
+ },
154
+ disconnect_app: {
155
+ connection_id: { positional: 0, label: "app-id", required: true },
156
+ },
100
157
  list_generations: {
101
158
  generator_id: { positional: 0, label: "enclosure-id", required: true },
102
159
  status: { flag: "status", enum: ["queued", "running", "done", "no_fit", "failed", "skipped"] },
@@ -111,8 +168,18 @@ const USAGE = `Usage: enclosures <list|show|create|update|prints>
111
168
  [--input website…] [--browser] [--login <app-connection-id>] [--stealth] [--record] [--region us-east|eu-west]
112
169
  [--image-model] [--tool run_site_audit…] [--share prospect|company_domain] [--fresh-days <n>]
113
170
  [--budget <usd>] [--skip-when <sentence>] [--no-disclosure]
171
+ [--cta-label "Book a time" --cta-url <https link>] [--no-cta]
114
172
  enclosures update <enclosure-id> [same flags]
115
- enclosures prints <enclosure-id> [--status done|failed|no_fit|…] [--limit <n>]`;
173
+ enclosures prints <enclosure-id> [--status done|failed|no_fit|…] [--limit <n>]
174
+ enclosures starters
175
+ enclosures create --starter site_audit --login <app-id> Begin from a starter (its version 1 included)
176
+ enclosures add-version <enclosure-id> (--script <code>|--script-file <path>) --step <text>… [--host <h>…] [--summary <s>]
177
+ enclosures approve <enclosure-id> --version <n>
178
+ enclosures print <enclosure-id> (--prospect <id>…|--list <list-id>) [--version <n>] [--redo] [--dry-run]
179
+ enclosures apps list
180
+ enclosures apps show <app-id>
181
+ enclosures apps connect <sign-in-url> [--name <n>] [--no-remember] Prints a sign-in link a person opens
182
+ enclosures apps disconnect <app-id> --yes`;
116
183
 
117
184
  export async function cmdEnclosures(ctx) {
118
185
  const sub = ctx.positionals[0];
@@ -130,6 +197,16 @@ export async function cmdEnclosures(ctx) {
130
197
  return enclosuresUpdate(rest);
131
198
  case "prints":
132
199
  return enclosuresPrints(rest);
200
+ case "apps":
201
+ return enclosuresApps(rest);
202
+ case "starters":
203
+ return enclosuresStarters(rest);
204
+ case "add-version":
205
+ return enclosuresAddVersion(rest);
206
+ case "approve":
207
+ return enclosuresApprove(rest);
208
+ case "print":
209
+ return enclosuresPrint(rest);
133
210
  default:
134
211
  throw new CliError(USAGE);
135
212
  }
@@ -208,3 +285,105 @@ async function enclosuresPrints(ctx) {
208
285
  });
209
286
  return 0;
210
287
  }
288
+
289
+ function formatApp(a) {
290
+ const state = { connected: "signed in", connecting: "signing in", needs_login: "signed out", error: "error" }[
291
+ a.status
292
+ ];
293
+ const lines = [`${a.id} ${a.name} [${state ?? a.status}] ${a.login_url}`];
294
+ if (a.last_error) lines.push(` ${a.last_error}`);
295
+ if (a.static_ip) lines.push(` requests come from ${a.static_ip}`);
296
+ return lines.join("\n");
297
+ }
298
+
299
+ async function enclosuresApps(ctx) {
300
+ const { client, flags, io } = ctx;
301
+ const sub = ctx.positionals[0];
302
+ const positionals = ctx.positionals.slice(1);
303
+ if (sub === "list" || sub === "ls") {
304
+ const result = await client.callTool("list_app_connections", argsFromFlags(ARGS.list_app_connections, flags));
305
+ printResult(io, flags, result, (r) =>
306
+ r.connections?.length ? r.connections.map(formatApp).join("\n\n") : "No apps yet."
307
+ );
308
+ return 0;
309
+ }
310
+ if (sub === "show" || sub === "get") {
311
+ const result = await client.callTool(
312
+ "get_app_connection",
313
+ argsFromFlags(ARGS.get_app_connection, flags, positionals)
314
+ );
315
+ printResult(io, flags, result, (r) => formatApp(r.connection));
316
+ return 0;
317
+ }
318
+ if (sub === "connect") {
319
+ refuseUnknownFlags({ ...ctx, positionals }, ARGS.connect_app, []);
320
+ const result = await client.callTool("connect_app", argsFromFlags(ARGS.connect_app, flags, positionals));
321
+ ok(ctx, `Open this page and sign in (it expires about 10 minutes after it opens):\n${result.hosted_url}`);
322
+ printResult(io, flags, result, (r) => formatApp(r.connection));
323
+ return 0;
324
+ }
325
+ if (sub === "disconnect") {
326
+ const id = requirePositional(positionals, 0, "app-id");
327
+ if (!flags.yes) {
328
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} enclosures apps disconnect ${id} --yes`);
329
+ return 1;
330
+ }
331
+ await client.callTool("disconnect_app", argsFromFlags(ARGS.disconnect_app, flags, positionals));
332
+ ok(ctx, "App removed. Enclosures that use it stop printing; prints already made stay.");
333
+ return 0;
334
+ }
335
+ throw new CliError(USAGE);
336
+ }
337
+
338
+ async function enclosuresStarters(ctx) {
339
+ const { client, flags, io } = ctx;
340
+ const result = await client.callTool("list_generator_starters", argsFromFlags(ARGS.list_generator_starters, flags));
341
+ printResult(io, flags, result, (r) =>
342
+ (r.starters ?? [])
343
+ .map((s) => `${s.key} ${s.name}: ${s.blurb}\n${s.steps.map((st) => ` ${st.n}. ${st.text}`).join("\n")}`)
344
+ .join("\n\n")
345
+ );
346
+ return 0;
347
+ }
348
+
349
+ async function enclosuresAddVersion(ctx) {
350
+ const { client, flags, positionals, io } = ctx;
351
+ refuseUnknownFlags(ctx, ARGS.add_generator_version, ["script", "script-file", "step", "region-selector"]);
352
+ const result = await client.callTool(
353
+ "add_generator_version",
354
+ argsFromFlags(ARGS.add_generator_version, flags, positionals)
355
+ );
356
+ ok(ctx, `Added version ${result.version}. Prints keep running the chosen version until you approve this one.`);
357
+ printResult(io, flags, result, (r) => `version ${r.version}`);
358
+ return 0;
359
+ }
360
+
361
+ async function enclosuresApprove(ctx) {
362
+ const { client, flags, positionals, io } = ctx;
363
+ refuseUnknownFlags(ctx, ARGS.approve_generator_version, []);
364
+ const result = await client.callTool(
365
+ "approve_generator_version",
366
+ argsFromFlags(ARGS.approve_generator_version, flags, positionals)
367
+ );
368
+ ok(ctx, `${result.generator?.name} now prints version ${result.generator?.current_version}.`);
369
+ printResult(io, flags, result, (r) => formatEnclosure(r.generator));
370
+ return 0;
371
+ }
372
+
373
+ async function enclosuresPrint(ctx) {
374
+ const { client, flags, positionals, io } = ctx;
375
+ refuseUnknownFlags(ctx, ARGS.print_generator, []);
376
+ const result = await client.callTool("print_generator", argsFromFlags(ARGS.print_generator, flags, positionals));
377
+ printResult(io, flags, result, (r) => {
378
+ const skipped = [
379
+ r.recent && `${r.recent} already have a recent one`,
380
+ r.no_website && `${r.no_website} have no website`,
381
+ r.never_emailed && `${r.never_emailed} unsubscribed`,
382
+ ].filter(Boolean);
383
+ const head = r.dry_run
384
+ ? `Would print ${r.will_print} (version ${r.version}): about $${r.estimate.usd}, about ${r.estimate.minutes} minutes.`
385
+ : `Printing ${r.queued} (version ${r.version}). Watch: enclosures prints ${positionals[0]}`;
386
+ return skipped.length ? `${head}\n${skipped.join(". ")}. They're skipped.` : head;
387
+ });
388
+ return 0;
389
+ }
@@ -373,13 +373,24 @@ async function siteMetrics(ctx) {
373
373
  );
374
374
  printResult(io, flags, result, (r) => {
375
375
  const lines = [`${r.host} ${r.window} (${r.range.start} to ${r.range.end}) source: ${r.source}`];
376
+ const counted = r.unit === "accesses";
376
377
  if (r.source !== "lettervisit") lines.push(` ${r.message}`);
377
378
  for (const p of r.pages) {
378
- const value = r.source === "lettervisit" ? p.accesses?.total : p.clicks;
379
- const delta = r.source === "lettervisit" ? p.accesses_delta_pct : p.clicks_delta_pct;
380
- const split = p.accesses
381
- ? ` (AI ${p.accesses.ai_agents}, crawlers ${p.accesses.search_crawlers}, people ${p.accesses.humans})`
379
+ const value = counted ? p.accesses?.total : p.clicks;
380
+ const delta = counted ? p.accesses_delta_pct : p.clicks_delta_pct;
381
+ const agents = p.accesses?.by_agent?.length
382
+ ? ` [${p.accesses.by_agent
383
+ .slice(0, 3)
384
+ .map((a) => `${a.agent} ${a.requests}`)
385
+ .join(", ")}]`
382
386
  : "";
387
+ const identified =
388
+ p.accesses?.identified_humans != null ? `, ${p.accesses.identified_humans} identified` : "";
389
+ const split = !p.accesses
390
+ ? ""
391
+ : r.source === "visitors"
392
+ ? ` (AI ${p.accesses.ai_agents}${agents}, people ${p.accesses.humans}${identified})`
393
+ : ` (AI ${p.accesses.ai_agents}, crawlers ${p.accesses.search_crawlers}, people ${p.accesses.humans})`;
383
394
  const marks = p.indicators.map((i) => i.label).join(", ");
384
395
  lines.push(
385
396
  ` ${p.path} ${value ?? "-"} ${r.unit ?? ""}${delta !== null ? ` ${delta > 0 ? "+" : ""}${delta}%` : ""}${split}${marks ? ` [${marks}]` : ""}`
@@ -464,7 +475,11 @@ async function siteFamilies(ctx) {
464
475
  r.families
465
476
  .map((f) => {
466
477
  const members = f.members.filter((m) => m.included);
467
- return `${f.id} ${f.name} [${f.status}] ${members.length} members (${f.source})`;
478
+ const line = `${f.id} ${f.name} [${f.status}] ${members.length} members (${f.source})`;
479
+ if (!f.coverage) return line;
480
+ const total = f.coverage.covered.length + f.coverage.missing.length;
481
+ const missing = f.coverage.missing.length ? ` missing: ${f.coverage.missing.join(", ")}` : "";
482
+ return `${line} ${f.coverage.covered.length} of ${total} ${f.universe?.list ?? ""}${missing}`;
468
483
  })
469
484
  .join("\n") || "No families yet."
470
485
  );
@@ -9,6 +9,7 @@
9
9
  // letterstory visits citations --since 30d
10
10
  // letterstory visits export --since 30d --format csv
11
11
  // letterstory visits sites
12
+ // letterstory visits pages --since 28d [--path '/docs/*'] [--agent openai-crawler]
12
13
 
13
14
  import { CliError } from "../client.mjs";
14
15
  import { flagStr, printResult, argsFromFlags } from "./shared.mjs";
@@ -27,6 +28,13 @@ export const ARGS = {
27
28
  purpose: { flag: "purpose", enum: PURPOSES },
28
29
  },
29
30
  visitors_get_citations: { site_id: "site", days },
31
+ visitors_get_page_traffic: {
32
+ site_id: "site",
33
+ days,
34
+ path: "path",
35
+ agent: "agent",
36
+ limit: { flag: "limit", type: "num" },
37
+ },
30
38
  visitors_compare_agents: {
31
39
  site_id: "site",
32
40
  a: { positional: 0, label: "agent-a (e.g. chatgpt)", required: true },
@@ -70,8 +78,10 @@ export async function cmdVisits(ctx) {
70
78
  return visitsExport(rest);
71
79
  case "sites":
72
80
  return visitsSites(rest);
81
+ case "pages":
82
+ return visitsPages(rest);
73
83
  default:
74
- throw new CliError("Usage: visits [list|citations|compare|export|sites]");
84
+ throw new CliError("Usage: visits [list|citations|compare|export|sites|pages]");
75
85
  }
76
86
  }
77
87
 
@@ -156,6 +166,29 @@ async function visitsExport(ctx) {
156
166
  return 0;
157
167
  }
158
168
 
169
+ async function visitsPages(ctx) {
170
+ const { flags, io } = ctx;
171
+ const result = await call(ctx, "visitors_get_page_traffic");
172
+ printResult(io, flags, result, (r) => {
173
+ if (r.unavailable) return r.message ?? `No page traffic (${r.unavailable}).`;
174
+ const t = r.totals;
175
+ const identified = t.identified_humans != null ? `, ${t.identified_humans} identified` : "";
176
+ const lines = [
177
+ `${r.host} ${r.since} to ${r.until}: ${t.humans} people page views (${t.unique_visitors} visitors${identified}), ${t.ai_requests} AI agent requests`,
178
+ ];
179
+ if (r.agents?.length) lines.push(`Agents: ${r.agents.map((a) => `${a.agent} ${a.requests}`).join(", ")}`);
180
+ if (!r.server_collected)
181
+ lines.push("No server snippet requests yet: AI agents need the server snippet (visits sites).");
182
+ lines.push("", " people AI page");
183
+ for (const p of r.pages.slice(0, 50)) {
184
+ const who = p.people?.length ? ` (${p.people.map((x) => x.name ?? x.id).join(", ")})` : "";
185
+ lines.push(` ${String(p.humans).padStart(6)} ${String(p.ai_requests).padStart(6)} ${p.path}${who}`);
186
+ }
187
+ return lines.join("\n");
188
+ });
189
+ return 0;
190
+ }
191
+
159
192
  async function visitsSites(ctx) {
160
193
  const { flags, io } = ctx;
161
194
  const result = await call(ctx, "visitors_list_sites");
package/lib/documents.mjs CHANGED
@@ -87,6 +87,8 @@ export const JSON_ARG_FLAG_PAIRS = [
87
87
  ["value", "value-file"],
88
88
  // A Vercel API token read verbatim, so it stays out of shell history (outbound.mjs mail vercel-key set).
89
89
  ["token", "token-file"],
90
+ // An enclosure version's script (enclosures.mjs add-version): code, read verbatim.
91
+ ["script", "script-file"],
90
92
  ];
91
93
 
92
94
  /** Lower-case extension without the dot. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.126.1",
3
+ "version": "0.128.2",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",