@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 +18 -2
- package/lib/commands/outbound.mjs +20 -1
- package/lib/commands/visits.mjs +175 -0
- package/lib/commands.mjs +1 -0
- package/package.json +1 -1
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.
|
|
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