@letterstory/cli 0.38.0 → 0.39.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
@@ -590,6 +590,20 @@ letterstory insights post <article-id> [--period 14d|30d|90d]
590
590
  letterstory insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
591
591
  ```
592
592
 
593
+ AI visibility and page access — the Measure tabs' reads, for scripts and agents:
594
+
595
+ ```bash
596
+ # The AI Visibility Bank: which questions named you, cited your pages, or both (a
597
+ # breakthrough). All time unless --period; --source picks the phantom network
598
+ # (default), your marketing site, or the whole portfolio; --phantom narrows to
599
+ # specific phantom sites (repeatable); --answers caps receipts per question.
600
+ letterstory insights bank [--source phantoms|marketing|all] [--phantom <uuid>]... [--period 14d|30d|90d] [--answers <n>]
601
+
602
+ # Page access: AI answer engines, search crawlers and confirmed human readers on
603
+ # your phantom sites; --collection narrows to the site(s) a collection publishes.
604
+ letterstory insights access [--period 14d|30d|90d] [--collection <uuid>]
605
+ ```
606
+
593
607
  ## Anything else
594
608
 
595
609
  Every Letterstory tool is reachable, not just the ones with a dedicated command group:
package/lib/cli.mjs CHANGED
@@ -47,10 +47,11 @@ import {
47
47
  cmdAdmin,
48
48
  cmdFeedback,
49
49
  cmdOfferings,
50
+ cmdTargets,
50
51
  } from "./commands.mjs";
51
52
 
52
53
  // Keep in sync with cli/package.json.
53
- export const VERSION = "0.38.0";
54
+ export const VERSION = "0.39.1";
54
55
 
55
56
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
56
57
  // can't accidentally swallow the id as --json's value.
@@ -116,6 +117,8 @@ const BOOLEAN_FLAGS = new Set([
116
117
  "none",
117
118
  // `prospects import|source --one-per-company`.
118
119
  "one-per-company",
120
+ // `targets update <id> --clear-framing`.
121
+ "clear-framing",
119
122
  ]);
120
123
 
121
124
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -344,6 +347,19 @@ Offerings (context layered org → offering → collection; a layer sets only wh
344
347
  series create --name <n> --description <d> [--coverage a,b,c] [--collection <uuid>] [--template <key>]
345
348
  series backfill <series-id> [--collection <uuid>] Reconstruct a series from existing posts
346
349
 
350
+ Ranking targets (what the org wants to be THE answer for — company-wide or on one offering):
351
+ targets list [--offering <offering-id|company>] Targets grouped by scope, each with its active cap and usage
352
+ targets add <phrase> [--framing <f>] [--offering <offering-id|company>]
353
+ Lands active while its scope has room, inactive after —
354
+ adding never fails on the cap
355
+ targets update <target-id> [--phrase <p>] [--framing <f> | --clear-framing] [--offering <offering-id|company>]
356
+ [--position <n>] [--state active|inactive|achieved|retired]
357
+ Moving an ACTIVE target needs room in the new scope
358
+ targets activate | deactivate <target-id> Switch a target on/off; activating is refused at the cap
359
+ targets cap <offering-id|company> <n|default> Active targets allowed in that scope (preset 5; default =
360
+ inherit the company's cap / back to the preset)
361
+ targets suggest [--offering <offering-id|company>] Evidence-informed suggestions (nothing saved)
362
+
347
363
  Arenas (adversarial clusters of research agents -> a sourced, fact-checked outline):
348
364
  arena run --topic <text> [--source <url> …] [--must-include <text> …] [--depth barebones|fleshed_out]
349
365
  [--gate] [--no-verify] [--provider native|exa|youcom|firecrawl] [--mention off|subtle|woven]
@@ -740,12 +756,17 @@ Prospects (who you write to):
740
756
  [--city] [--country] [--years] [--notes] [--custom key=value]...
741
757
 
742
758
  Mail:
743
- mail connections Connected mailboxes, Google connect + Vercel key status
759
+ mail connections Connected mailboxes, Google connect, Vercel key + mailing address status
760
+ mail postal-address "<address>" Set the org's CAN-SPAM mailing address (email footers)
744
761
 
745
762
  Insights:
746
763
  insights site [--period 14d|30d|90d] [--collection <uuid>]
747
764
  insights post <article-id> [--period 14d|30d|90d]
748
765
  insights top [--period 14d|30d|90d] [--collection <uuid>] [--limit <n>] [--sort clicks|impressions]
766
+ insights bank [--source phantoms|marketing|all] [--phantom <uuid>]... [--period 14d|30d|90d] [--answers <n>]
767
+ AI Visibility Bank: named / cited / breakthrough evidence (all time by default)
768
+ insights access [--period 14d|30d|90d] [--collection <uuid>]
769
+ Page access: who's reading your phantom sites
749
770
 
750
771
  Feedback (tell us what's broken, missing, unclear, or wanted):
751
772
  feedback submit --category bug|missing_feature|unclear|feature_request|praise|other
@@ -802,6 +823,7 @@ const CLIENT_COMMANDS = {
802
823
  strategy: cmdStrategy,
803
824
  directives: cmdDirectives,
804
825
  offerings: cmdOfferings,
826
+ targets: cmdTargets,
805
827
  probes: cmdProbes,
806
828
  suggestions: cmdSuggestions,
807
829
  onboarding: cmdOnboarding,
@@ -1,9 +1,25 @@
1
1
  // `insights` — Search Console impressions/clicks across the phantom network, at the
2
- // network, single-post, and top-posts levels. Ported from phantomstory-cli's
3
- // insights.ts.
2
+ // network, single-post, and top-posts levels (ported from phantomstory-cli's
3
+ // insights.ts); plus Measure's AI Visibility Bank (`bank`) and phantom Page
4
+ // Access (`access`) — the same reads the web app's Measure tabs render.
4
5
 
5
6
  import { CliError } from "../client.mjs";
6
- import { flagStr, flagNum, requirePositional, printResult, compact } from "./shared.mjs";
7
+ import { flagStr, flagNum, requirePositional, printResult, compact, argsFromFlags } from "./shared.mjs";
8
+
9
+ const PERIODS = ["14d", "30d", "90d"];
10
+
11
+ export const ARGS = {
12
+ get_ai_visibility_bank: {
13
+ source: { flag: "source", enum: ["phantoms", "marketing", "all"] },
14
+ phantom_ids: { flag: "phantom", type: "list" },
15
+ period: { flag: "period", enum: PERIODS },
16
+ answers_per_prompt: { flag: "answers", type: "num" },
17
+ },
18
+ get_page_access: {
19
+ period: { flag: "period", enum: PERIODS },
20
+ collection_id: { flag: "collection" },
21
+ },
22
+ };
7
23
 
8
24
  export async function cmdInsights(ctx) {
9
25
  const sub = ctx.positionals[0];
@@ -15,8 +31,12 @@ export async function cmdInsights(ctx) {
15
31
  return insightsPost(rest);
16
32
  case "top":
17
33
  return insightsTop(rest);
34
+ case "bank":
35
+ return call(rest, "get_ai_visibility_bank");
36
+ case "access":
37
+ return call(rest, "get_page_access");
18
38
  default:
19
- throw new CliError(`Unknown insights subcommand: ${sub ?? "(none)"}. Try: site, post, top`);
39
+ throw new CliError(`Unknown insights subcommand: ${sub ?? "(none)"}. Try: site, post, top, bank, access`);
20
40
  }
21
41
  }
22
42
 
@@ -49,3 +69,9 @@ async function insightsTop(ctx) {
49
69
  printResult(io, flags, result);
50
70
  return 0;
51
71
  }
72
+
73
+ async function call(ctx, tool) {
74
+ const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
75
+ printResult(ctx.io, ctx.flags, result);
76
+ return 0;
77
+ }
@@ -167,6 +167,9 @@ export const ARGS = {
167
167
  custom: { value: readCustom },
168
168
  },
169
169
  get_mail_connections: {},
170
+ set_mail_postal_address: {
171
+ mail_postal_address: { positional: 0, label: "address", required: true },
172
+ },
170
173
  };
171
174
 
172
175
  // Flags the computed `value` sources read (refuseUnknownFlags can't see them).
@@ -318,7 +321,9 @@ export async function cmdMail(ctx) {
318
321
  case "connections":
319
322
  case "status":
320
323
  return run(rest, "get_mail_connections");
324
+ case "postal-address":
325
+ return run(rest, "set_mail_postal_address", "Mailing address saved.");
321
326
  default:
322
- throw new CliError(`Unknown mail subcommand: ${sub ?? "(none)"}. Try: connections`);
327
+ throw new CliError(`Unknown mail subcommand: ${sub ?? "(none)"}. Try: connections, postal-address`);
323
328
  }
324
329
  }
@@ -0,0 +1,223 @@
1
+ // `targets` — ranking targets: what the org wants to be THE answer for.
2
+ //
3
+ // A target belongs to the company (company-wide) or to one offering. Each scope — the
4
+ // company layer and every offering — holds its own ACTIVE targets up to its cap (a preset
5
+ // of 5, adjustable per scope with `targets cap`); inactive targets are never capped, so
6
+ // `add` never fails on the cap: it lands inactive when the scope is full. Thin wrappers
7
+ // over the MCP tools, which call the same store functions the app's /api/ranking-targets
8
+ // routes and staff Fleet do.
9
+
10
+ import { CliError } from "../client.mjs";
11
+ import { flagStr, flagNum, printResult, argsFromFlags, refuseUnknownFlags, ok } from "./shared.mjs";
12
+
13
+ const STATUSES = ["active", "inactive", "achieved", "retired"];
14
+ const COMPANY = "company";
15
+
16
+ // `--offering <offering-id|company>`: company = the company layer (null). Absent = not sent.
17
+ function readOffering(flags) {
18
+ const v = flagStr(flags.offering);
19
+ if (v === undefined) {
20
+ if (flags.offering === true) throw new CliError("--offering takes an offering id, or `company`");
21
+ return undefined;
22
+ }
23
+ return v === COMPANY ? null : v;
24
+ }
25
+ const offering = { value: readOffering };
26
+
27
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
28
+ // `scripts/parity-check.ts` compares these keys against each tool's published input schema.
29
+ export const ARGS = {
30
+ list_ranking_targets: { offering_id: offering },
31
+ add_ranking_target: {
32
+ // The phrase is every positional, so `targets add best payroll software` needs no quotes.
33
+ phrase: {
34
+ value: (_flags, positionals) => {
35
+ const phrase = positionals.join(" ").trim();
36
+ if (!phrase) throw new CliError("Missing required <phrase>");
37
+ return phrase;
38
+ },
39
+ },
40
+ mention_framing: "framing",
41
+ offering_id: offering,
42
+ },
43
+ update_ranking_target: {
44
+ target_id: { positional: 0, label: "target-id", required: true },
45
+ phrase: "phrase",
46
+ mention_framing: {
47
+ value: (flags) => (flags["clear-framing"] === true ? null : flagStr(flags.framing)),
48
+ },
49
+ // `activate` / `deactivate` set this for you.
50
+ status: { flag: "state", enum: STATUSES },
51
+ offering_id: offering,
52
+ position: { flag: "position", type: "num" },
53
+ },
54
+ set_ranking_target_cap: {
55
+ offering_id: {
56
+ value: (_flags, positionals) => {
57
+ const scope = positionals[0];
58
+ if (scope === undefined) throw new CliError("Missing required <offering-id|company>");
59
+ return scope === COMPANY ? null : scope;
60
+ },
61
+ },
62
+ // `default` = back to inheriting (an offering → the company's cap; the company → the preset).
63
+ cap: {
64
+ value: (_flags, positionals) => {
65
+ const raw = positionals[1];
66
+ if (raw === undefined) throw new CliError("Missing required <n|default>");
67
+ return raw === "default" ? null : flagNum(raw);
68
+ },
69
+ },
70
+ },
71
+ suggest_ranking_targets: { offering_id: offering },
72
+ };
73
+
74
+ const USAGE = `Usage: targets <list|add|update|activate|deactivate|cap|suggest>
75
+ targets list [--offering <offering-id|company>]
76
+ targets add <phrase> [--framing <f>] [--offering <offering-id|company>]
77
+ targets update <target-id> [--phrase <p>] [--framing <f> | --clear-framing] [--offering <offering-id|company>]
78
+ [--position <n>] [--state active|inactive|achieved|retired]
79
+ targets activate <target-id>
80
+ targets deactivate <target-id>
81
+ targets cap <offering-id|company> <n|default>
82
+ targets suggest [--offering <offering-id|company>]`;
83
+
84
+ export async function cmdTargets(ctx) {
85
+ const sub = ctx.positionals[0];
86
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
87
+ switch (sub) {
88
+ case "list":
89
+ case "ls":
90
+ return targetsList(rest);
91
+ case "add":
92
+ return targetsAdd(rest);
93
+ case "update":
94
+ return targetsUpdate(rest);
95
+ case "activate":
96
+ return targetsSetStatus(rest, "active");
97
+ case "deactivate":
98
+ return targetsSetStatus(rest, "inactive");
99
+ case "cap":
100
+ return targetsCap(rest);
101
+ case "suggest":
102
+ return targetsSuggest(rest);
103
+ default:
104
+ throw new CliError(USAGE);
105
+ }
106
+ }
107
+
108
+ const scopeName = (s) => (s.offeringId === null ? "Company-wide" : s.label);
109
+
110
+ function formatScope(s) {
111
+ const where = s.capSetHere ? "set here" : s.offeringId === null ? "preset" : "company's cap";
112
+ return `${scopeName(s)} — ${s.active}/${s.cap} active (${where})`;
113
+ }
114
+
115
+ function formatTarget(t) {
116
+ const framing = t.mention_framing ? ` — as ${t.mention_framing}` : "";
117
+ return ` ${t.id} [${t.status}] ${t.phrase}${framing}`;
118
+ }
119
+
120
+ // Targets grouped under their scope, each headed by its cap and usage. A target on an
121
+ // offering the scopes list doesn't carry (archived) still shows, under its id.
122
+ export function formatTargets(r) {
123
+ const scopes = r.scopes ?? [];
124
+ const items = r.items ?? [];
125
+ if (!items.length && !scopes.length) return "No ranking targets yet.";
126
+ const lines = [];
127
+ const shown = new Set();
128
+ for (const s of scopes) {
129
+ const mine = items.filter((t) => (t.offering_id ?? null) === s.offeringId);
130
+ lines.push(formatScope(s));
131
+ for (const t of mine) {
132
+ lines.push(formatTarget(t));
133
+ shown.add(t.id);
134
+ }
135
+ if (!mine.length) lines.push(" (none)");
136
+ }
137
+ const orphans = items.filter((t) => !shown.has(t.id));
138
+ for (const id of [...new Set(orphans.map((t) => t.offering_id))]) {
139
+ lines.push(`Offering ${id} (not active)`);
140
+ for (const t of orphans.filter((o) => o.offering_id === id)) lines.push(formatTarget(t));
141
+ }
142
+ return lines.join("\n");
143
+ }
144
+
145
+ async function targetsList(ctx) {
146
+ const { client, flags, io } = ctx;
147
+ const result = await client.callTool("list_ranking_targets", argsFromFlags(ARGS.list_ranking_targets, flags));
148
+ printResult(io, flags, result, formatTargets);
149
+ return 0;
150
+ }
151
+
152
+ async function targetsAdd(ctx) {
153
+ const { client, flags, positionals, io } = ctx;
154
+ refuseUnknownFlags(ctx, ARGS.add_ranking_target, ["offering"]);
155
+ const result = await client.callTool(
156
+ "add_ranking_target",
157
+ argsFromFlags(ARGS.add_ranking_target, flags, positionals)
158
+ );
159
+ const t = result.target;
160
+ ok(
161
+ ctx,
162
+ t?.status === "active"
163
+ ? `Added "${t.phrase}" — active.`
164
+ : `Added "${t?.phrase}" as inactive: its scope is at its active cap. Deactivate one there, or raise the cap (targets cap), then: ${ctx.bin} targets activate ${t?.id}`
165
+ );
166
+ printResult(io, flags, result, (r) => formatTarget(r.target));
167
+ return 0;
168
+ }
169
+
170
+ async function update(ctx, flags) {
171
+ const { client, positionals, io } = ctx;
172
+ const args = argsFromFlags(ARGS.update_ranking_target, flags, positionals);
173
+ if (Object.keys(args).length === 1) {
174
+ throw new CliError(
175
+ "Nothing to update — pass --phrase, --framing, --clear-framing, --offering, --position or --state."
176
+ );
177
+ }
178
+ const result = await client.callTool("update_ranking_target", args);
179
+ ok(ctx, `Updated "${result.target?.phrase}" — ${result.target?.status}.`);
180
+ printResult(io, ctx.flags, result, (r) => formatTarget(r.target));
181
+ return 0;
182
+ }
183
+
184
+ async function targetsUpdate(ctx) {
185
+ refuseUnknownFlags(ctx, ARGS.update_ranking_target, ["framing", "clear-framing", "offering"]);
186
+ return update(ctx, ctx.flags);
187
+ }
188
+
189
+ // Activate / deactivate are the first-class switch. Activating is refused when the
190
+ // target's scope is at its cap — the server's message names the scope.
191
+ async function targetsSetStatus(ctx, status) {
192
+ refuseUnknownFlags(ctx, {});
193
+ return update(ctx, { ...ctx.flags, state: status });
194
+ }
195
+
196
+ async function targetsCap(ctx) {
197
+ const { client, flags, positionals, io } = ctx;
198
+ refuseUnknownFlags(ctx, {});
199
+ const result = await client.callTool(
200
+ "set_ranking_target_cap",
201
+ argsFromFlags(ARGS.set_ranking_target_cap, flags, positionals)
202
+ );
203
+ ok(ctx, "Cap saved.");
204
+ printResult(io, flags, result, (r) => (r.scopes ?? []).map(formatScope).join("\n"));
205
+ return 0;
206
+ }
207
+
208
+ async function targetsSuggest(ctx) {
209
+ const { client, flags, io } = ctx;
210
+ const result = await client.callTool("suggest_ranking_targets", argsFromFlags(ARGS.suggest_ranking_targets, flags));
211
+ printResult(io, flags, result, (r) => {
212
+ const all = r.suggestions ?? [];
213
+ if (!all.length) return "No suggestions yet — add positioning first.";
214
+ const scope = r.offering_id ? ` --offering ${r.offering_id}` : "";
215
+ const lines = all.map(
216
+ (s) =>
217
+ ` ${s.phrase} [${s.basis}]${s.suggestedFraming ? ` — as ${s.suggestedFraming}` : ""}\n ${s.rationale}`
218
+ );
219
+ lines.push(`Nothing saved. Keep one with: ${ctx.bin} targets add <phrase>${scope}`);
220
+ return lines.join("\n");
221
+ });
222
+ return 0;
223
+ }
package/lib/commands.mjs CHANGED
@@ -34,5 +34,6 @@ export * from "./commands/shredder.mjs";
34
34
  export * from "./commands/landings.mjs";
35
35
  export * from "./commands/outbound.mjs";
36
36
  export * from "./commands/offerings.mjs";
37
+ export * from "./commands/targets.mjs";
37
38
  export * from "./commands/admin.mjs";
38
39
  export * from "./commands/feedback.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.38.0",
3
+ "version": "0.39.1",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",