@letterstory/cli 0.93.1 → 0.94.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/lib/cli.mjs CHANGED
@@ -54,10 +54,11 @@ import {
54
54
  cmdEvals,
55
55
  cmdOfferings,
56
56
  cmdTargets,
57
+ cmdVisits,
57
58
  } from "./commands.mjs";
58
59
 
59
60
  // Keep in sync with cli/package.json.
60
- export const VERSION = "0.93.1";
61
+ export const VERSION = "0.94.0";
61
62
 
62
63
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
63
64
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -66,6 +67,8 @@ export const BOOLEAN_FLAGS = new Set([
66
67
  "json",
67
68
  // lede library update / lede proposals accept: clear or drop the line's condition.
68
69
  "no-condition",
70
+ // prospects update: clear the HeyReach LinkedIn-reply marker.
71
+ "clear-linkedin-reply",
69
72
  "yes",
70
73
  "confirm-missing-target",
71
74
  "no-wait",
@@ -1016,6 +1019,9 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
1016
1019
  sequences cakes sync Re-read the connected Daymaker account now
1017
1020
  sequences cakes connect --daymaker-key <dm_live_…> Connect the org's Daymaker account (key checked, stored encrypted)
1018
1021
  sequences cakes disconnect Forget the key and remove the webhook
1022
+ sequences heyreach Whether the org has a HeyReach webhook (LinkedIn replies stop email)
1023
+ sequences heyreach rotate Make (or rotate) the HeyReach webhook URL; staff org admins
1024
+ sequences heyreach disconnect Remove the HeyReach webhook URL
1019
1025
 
1020
1026
  Dispatches (the named review desks a sequence's review stage queues to):
1021
1027
  dispatches list Desks + how many emails each has queued
@@ -1045,7 +1051,7 @@ Prospects (who you write to):
1045
1051
  prospects columns delete <column-id> --yes
1046
1052
  prospects update <prospect-id> [--status new|contacted|replied|unsubscribed|archived] [--name <n>]
1047
1053
  [--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
1048
- [--city] [--country] [--years] [--notes] [--custom key=value]...
1054
+ [--city] [--country] [--years] [--notes] [--custom key=value]... [--clear-linkedin-reply]
1049
1055
 
1050
1056
  Mailers (the sending mailboxes; launching one buys a domain, so use: call launch_mailers):
1051
1057
  mailers list [--domain <apex>] Your mailers + status + daily send limit
@@ -1086,6 +1092,15 @@ Feedback (tell us what's broken, missing, unclear, or wanted):
1086
1092
  [--message <text>|--body <text>|--file <path>|--file -] [--rating 1-5]
1087
1093
  [--context '<json>']
1088
1094
 
1095
+ Visits (Visitors: AI agents reading your site, and AI answers sending people to it):
1096
+ visits [--agent <a>] [--since 7d] [--path '/docs/*'] [--purpose live|index|training|automated_browser] [--site <id>]
1097
+ Agent visits: per agent, why they came, most-read pages
1098
+ visits citations [--since 30d] [--site <id>] People who clicked through from ChatGPT, Claude, Perplexity…
1099
+ visits compare <agent-a> <agent-b> [--since 7d] [--path '/docs/*'] [--purpose <p>]
1100
+ Pages one agent read that the other didn't
1101
+ visits export [--since 30d] [--format json|csv] [--table agent_visits|citations]
1102
+ visits sites Your Visitors sites and their install checks
1103
+
1089
1104
  Evals (how the pipeline is doing on your posts, and the regression cases captured from them):
1090
1105
  evals scoreboard [--collection <id>] [--days N] Every measure by stage: posts, failing, rate
1091
1106
  evals cases [--status open|guarding|proposed|retired|rejected] [--stage research|brief|links|mentions|accuracy|pipeline|shred]
@@ -1177,6 +1192,7 @@ const CLIENT_COMMANDS = {
1177
1192
  mailers: cmdMailers,
1178
1193
  feedback: cmdFeedback,
1179
1194
  evals: cmdEvals,
1195
+ visits: cmdVisits,
1180
1196
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
1181
1197
  admin: cmdAdmin,
1182
1198
  };
@@ -190,6 +190,9 @@ export const ARGS = {
190
190
  sync_cakes: {},
191
191
  connect_daymaker: { api_key: { flag: "daymaker-key", required: true } },
192
192
  disconnect_daymaker: {},
193
+ get_heyreach_webhook: {},
194
+ rotate_heyreach_webhook: {},
195
+ disconnect_heyreach_webhook: {},
193
196
  enroll_in_sequence: {
194
197
  sequence_id: { positional: 0, label: "sequence-id", required: true },
195
198
  recipients: { value: readRecipients },
@@ -328,6 +331,7 @@ export const ARGS = {
328
331
  country: "country",
329
332
  years_at_company: "years",
330
333
  notes: "notes",
334
+ linkedin_replied_at: { value: (flags) => (flags["clear-linkedin-reply"] ? null : undefined) },
331
335
  custom: { value: readCustom },
332
336
  },
333
337
  list_mailers: {
@@ -397,7 +401,7 @@ async function run(ctx, tool, message) {
397
401
  return 0;
398
402
  }
399
403
 
400
- const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links|cakes>`;
404
+ const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links|cakes|heyreach>`;
401
405
  const DISPATCHES_USAGE = `Usage: dispatches <list|create|rename|delete>`;
402
406
 
403
407
  // Dispatches: the named review desks a sequence's review stage queues to.
@@ -478,6 +482,21 @@ export async function cmdSequences(ctx) {
478
482
  return run(rest, "list_cakes");
479
483
  }
480
484
  }
485
+ case "heyreach": {
486
+ const heyreachCtx = { ...rest, positionals: rest.positionals.slice(1) };
487
+ switch (rest.positionals[0]) {
488
+ case "rotate":
489
+ return run(
490
+ heyreachCtx,
491
+ "rotate_heyreach_webhook",
492
+ "HeyReach webhook URL made; paste it into HeyReach."
493
+ );
494
+ case "disconnect":
495
+ return run(heyreachCtx, "disconnect_heyreach_webhook", "HeyReach webhook removed.");
496
+ default:
497
+ return run(rest, "get_heyreach_webhook");
498
+ }
499
+ }
481
500
  default:
482
501
  throw new CliError(`Unknown sequences subcommand: ${sub ?? "(none)"}. ${SEQUENCES_USAGE}`);
483
502
  }
@@ -0,0 +1,175 @@
1
+ // `visits` — Visitors from a terminal: which AI agents read your site, why they
2
+ // came, which AI answers sent people to you, and two agents side by side. Thin
3
+ // wrappers over the visitors_* tools (visitors_get_agent_visits,
4
+ // visitors_get_citations, visitors_compare_agents, visitors_export, …), the same
5
+ // lib functions the app and lettervisit.com/mcp call.
6
+ //
7
+ // letterstory visits --agent claude --since 7d
8
+ // letterstory visits compare chatgpt claude --path '/docs/*' --since 7d
9
+ // letterstory visits citations --since 30d
10
+ // letterstory visits export --since 30d --format csv
11
+ // letterstory visits sites
12
+
13
+ import { CliError } from "../client.mjs";
14
+ import { flagStr, printResult, argsFromFlags } from "./shared.mjs";
15
+
16
+ const PURPOSES = ["live", "index", "training", "automated_browser", "unknown"];
17
+
18
+ const days = { value: (flags) => sinceDays(flags.since) };
19
+
20
+ export const ARGS = {
21
+ visitors_list_sites: {},
22
+ visitors_get_agent_visits: {
23
+ site_id: "site",
24
+ days,
25
+ agent: "agent",
26
+ path: "path",
27
+ purpose: { flag: "purpose", enum: PURPOSES },
28
+ },
29
+ visitors_get_citations: { site_id: "site", days },
30
+ visitors_compare_agents: {
31
+ site_id: "site",
32
+ a: { positional: 0, label: "agent-a (e.g. chatgpt)", required: true },
33
+ b: { positional: 1, label: "agent-b (e.g. claude)", required: true },
34
+ days,
35
+ path: "path",
36
+ purpose: { flag: "purpose", enum: PURPOSES },
37
+ },
38
+ visitors_export: { site_id: "site", days, format: { flag: "format", enum: ["json", "csv"] } },
39
+ };
40
+
41
+ /** `7d`, `7`, `2w` → days (1-90). Absent → undefined (the tool's default, 30). */
42
+ export function sinceDays(v) {
43
+ const s = flagStr(v);
44
+ if (!s) return undefined;
45
+ const m = /^(\d+)\s*([dw]?)$/i.exec(s.trim());
46
+ if (!m) throw new CliError(`--since takes days, e.g. 7d, 30 or 2w (got "${s}")`);
47
+ const n = Number(m[1]) * (m[2].toLowerCase() === "w" ? 7 : 1);
48
+ if (n < 1 || n > 90) throw new CliError("--since must be between 1 and 90 days");
49
+ return n;
50
+ }
51
+
52
+ /** Call a visitors_* tool with the args its ARGS map builds from this command's flags. */
53
+ function call(ctx, tool) {
54
+ return ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
55
+ }
56
+
57
+ export async function cmdVisits(ctx) {
58
+ const sub = ctx.positionals[0];
59
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
60
+ switch (sub) {
61
+ case undefined:
62
+ case "list":
63
+ case "ls":
64
+ return visitsList(sub === undefined ? ctx : rest);
65
+ case "citations":
66
+ return visitsCitations(rest);
67
+ case "compare":
68
+ return visitsCompare(rest);
69
+ case "export":
70
+ return visitsExport(rest);
71
+ case "sites":
72
+ return visitsSites(rest);
73
+ default:
74
+ throw new CliError("Usage: visits [list|citations|compare|export|sites]");
75
+ }
76
+ }
77
+
78
+ const PURPOSE_LABELS = {
79
+ live: "answering a user",
80
+ index: "search index",
81
+ training: "training crawl",
82
+ automated_browser: "browser agent",
83
+ unknown: "unlabelled",
84
+ };
85
+
86
+ async function visitsList(ctx) {
87
+ const { flags, io } = ctx;
88
+ const result = await call(ctx, "visitors_get_agent_visits");
89
+ printResult(io, flags, result, (r) => {
90
+ const lines = [`${r.total} agent visits since ${r.since} (${r.verified} verified)`];
91
+ const why = Object.entries(r.by_purpose ?? {})
92
+ .filter(([, n]) => n > 0)
93
+ .map(([p, n]) => `${PURPOSE_LABELS[p] ?? p} ${n}`);
94
+ if (why.length) lines.push(`Why: ${why.join(", ")}`);
95
+ if (r.agents?.length) {
96
+ lines.push("", "Agents:");
97
+ for (const a of r.agents)
98
+ lines.push(` ${a.agent.padEnd(14)} ${String(a.total).padStart(6)} (${a.verified} verified)`);
99
+ }
100
+ if (r.top_paths?.length) {
101
+ lines.push("", "Most read:");
102
+ for (const p of r.top_paths.slice(0, 10)) lines.push(` ${String(p.total).padStart(6)} ${p.path}`);
103
+ }
104
+ if (!r.total) lines.push("No agent visits in this window. Is the server snippet installed? (visits sites)");
105
+ return lines.join("\n");
106
+ });
107
+ return 0;
108
+ }
109
+
110
+ async function visitsCitations(ctx) {
111
+ const { flags, io } = ctx;
112
+ const result = await call(ctx, "visitors_get_citations");
113
+ printResult(io, flags, result, (r) => {
114
+ const lines = [`${r.total} people arrived from an AI answer since ${r.since}`];
115
+ for (const s of r.by_source ?? [])
116
+ lines.push(` ${s.source.padEnd(12)} ${String(s.visits).padStart(6)} avg ${s.avg_seconds}s`);
117
+ if (r.top_pages?.length) {
118
+ lines.push("", "Landing pages:");
119
+ for (const p of r.top_pages.slice(0, 10)) lines.push(` ${String(p.visits).padStart(6)} ${p.path}`);
120
+ }
121
+ return lines.join("\n");
122
+ });
123
+ return 0;
124
+ }
125
+
126
+ async function visitsCompare(ctx) {
127
+ const { flags, io } = ctx;
128
+ const result = await call(ctx, "visitors_compare_agents");
129
+ printResult(io, flags, result, (r) => {
130
+ const lines = [
131
+ `${r.a.agent}: ${r.a.total} visits on ${r.a.pages} pages · ${r.b.agent}: ${r.b.total} visits on ${r.b.pages} pages (since ${r.since})`,
132
+ ];
133
+ lines.push("", `Only ${r.a.agent}:`);
134
+ for (const p of r.only_a) lines.push(` ${String(p.a).padStart(6)} ${p.path}`);
135
+ if (!r.only_a.length) lines.push(" (none)");
136
+ lines.push("", `Only ${r.b.agent}:`);
137
+ for (const p of r.only_b) lines.push(` ${String(p.b).padStart(6)} ${p.path}`);
138
+ if (!r.only_b.length) lines.push(" (none)");
139
+ return lines.join("\n");
140
+ });
141
+ return 0;
142
+ }
143
+
144
+ async function visitsExport(ctx) {
145
+ const { flags, io } = ctx;
146
+ const result = await call(ctx, "visitors_export");
147
+ if (flagStr(flags.format) === "csv" && !flags.json && result.csv) {
148
+ // --table picks one; default is agent visits (pipe to a file).
149
+ const table = flagStr(flags.table) ?? "agent_visits";
150
+ if (table !== "agent_visits" && table !== "citations")
151
+ throw new CliError("--table is agent_visits or citations");
152
+ io.log(result.csv[table]);
153
+ return 0;
154
+ }
155
+ io.log(JSON.stringify(result, null, 2));
156
+ return 0;
157
+ }
158
+
159
+ async function visitsSites(ctx) {
160
+ const { flags, io } = ctx;
161
+ const result = await call(ctx, "visitors_list_sites");
162
+ printResult(io, flags, result, (r) => {
163
+ if (!r.sites?.length) return "No Visitors sites yet. Add one at https://lettervisit.com.";
164
+ return r.sites
165
+ .map((s) => {
166
+ const checks = [
167
+ s.script_seen_at ? "script ✓" : "script ✗",
168
+ s.server_seen_at ? "server snippet ✓" : "server snippet ✗",
169
+ ].join(", ");
170
+ return `${s.site_id} ${s.domain ?? s.name} (${checks})`;
171
+ })
172
+ .join("\n");
173
+ });
174
+ return 0;
175
+ }
package/lib/commands.mjs CHANGED
@@ -41,3 +41,4 @@ export * from "./commands/targets.mjs";
41
41
  export * from "./commands/admin.mjs";
42
42
  export * from "./commands/feedback.mjs";
43
43
  export * from "./commands/evals.mjs";
44
+ export * from "./commands/visits.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.93.1",
3
+ "version": "0.94.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",