@letterstory/cli 0.93.1 → 0.95.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/README.md +1 -0
- package/lib/cli.mjs +22 -2
- package/lib/commands/admin.mjs +26 -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/README.md
CHANGED
|
@@ -353,6 +353,7 @@ long-running agent can watch the fleet on a schedule without a browser.
|
|
|
353
353
|
# every client against itself on share of voice, mention rate and page access, with what we
|
|
354
354
|
# did for it. Needs fleet:read; `run` and `ask` spend Fable tokens and need fleet:query.
|
|
355
355
|
letterstory admin patterns --period 30d
|
|
356
|
+
letterstory admin patterns kernels --by version --outcome impressions14 # what each kernel/version change did
|
|
356
357
|
letterstory admin patterns reports # Fable's weekly reports and runs, newest first
|
|
357
358
|
letterstory admin patterns report <id> # one report's markdown
|
|
358
359
|
letterstory admin patterns run # a report now (takes minutes)
|
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.95.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",
|
|
@@ -657,6 +660,10 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
657
660
|
Client patterns: every client's share of voice,
|
|
658
661
|
mention rate and page access against itself, with
|
|
659
662
|
what we did for it. Needs fleet:read.
|
|
663
|
+
admin patterns kernels [--by version|family] [--outcome cited14|read14|named|impressions14|clicks14|cited28|impressions28]
|
|
664
|
+
What each kernel and version change did: the client's
|
|
665
|
+
change minus clients that held still, pooled by
|
|
666
|
+
transition. Needs fleet:read.
|
|
660
667
|
admin patterns reports [--kind reports|weekly|run|ask] | report <id>
|
|
661
668
|
Fable's weekly reports, runs and Ask Fable answers.
|
|
662
669
|
admin patterns run | ask "<question>" Run a Fable report now, or ask Fable a question
|
|
@@ -1016,6 +1023,9 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
|
|
|
1016
1023
|
sequences cakes sync Re-read the connected Daymaker account now
|
|
1017
1024
|
sequences cakes connect --daymaker-key <dm_live_…> Connect the org's Daymaker account (key checked, stored encrypted)
|
|
1018
1025
|
sequences cakes disconnect Forget the key and remove the webhook
|
|
1026
|
+
sequences heyreach Whether the org has a HeyReach webhook (LinkedIn replies stop email)
|
|
1027
|
+
sequences heyreach rotate Make (or rotate) the HeyReach webhook URL; staff org admins
|
|
1028
|
+
sequences heyreach disconnect Remove the HeyReach webhook URL
|
|
1019
1029
|
|
|
1020
1030
|
Dispatches (the named review desks a sequence's review stage queues to):
|
|
1021
1031
|
dispatches list Desks + how many emails each has queued
|
|
@@ -1045,7 +1055,7 @@ Prospects (who you write to):
|
|
|
1045
1055
|
prospects columns delete <column-id> --yes
|
|
1046
1056
|
prospects update <prospect-id> [--status new|contacted|replied|unsubscribed|archived] [--name <n>]
|
|
1047
1057
|
[--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
|
|
1048
|
-
[--city] [--country] [--years] [--notes] [--custom key=value]...
|
|
1058
|
+
[--city] [--country] [--years] [--notes] [--custom key=value]... [--clear-linkedin-reply]
|
|
1049
1059
|
|
|
1050
1060
|
Mailers (the sending mailboxes; launching one buys a domain, so use: call launch_mailers):
|
|
1051
1061
|
mailers list [--domain <apex>] Your mailers + status + daily send limit
|
|
@@ -1086,6 +1096,15 @@ Feedback (tell us what's broken, missing, unclear, or wanted):
|
|
|
1086
1096
|
[--message <text>|--body <text>|--file <path>|--file -] [--rating 1-5]
|
|
1087
1097
|
[--context '<json>']
|
|
1088
1098
|
|
|
1099
|
+
Visits (Visitors: AI agents reading your site, and AI answers sending people to it):
|
|
1100
|
+
visits [--agent <a>] [--since 7d] [--path '/docs/*'] [--purpose live|index|training|automated_browser] [--site <id>]
|
|
1101
|
+
Agent visits: per agent, why they came, most-read pages
|
|
1102
|
+
visits citations [--since 30d] [--site <id>] People who clicked through from ChatGPT, Claude, Perplexity…
|
|
1103
|
+
visits compare <agent-a> <agent-b> [--since 7d] [--path '/docs/*'] [--purpose <p>]
|
|
1104
|
+
Pages one agent read that the other didn't
|
|
1105
|
+
visits export [--since 30d] [--format json|csv] [--table agent_visits|citations]
|
|
1106
|
+
visits sites Your Visitors sites and their install checks
|
|
1107
|
+
|
|
1089
1108
|
Evals (how the pipeline is doing on your posts, and the regression cases captured from them):
|
|
1090
1109
|
evals scoreboard [--collection <id>] [--days N] Every measure by stage: posts, failing, rate
|
|
1091
1110
|
evals cases [--status open|guarding|proposed|retired|rejected] [--stage research|brief|links|mentions|accuracy|pipeline|shred]
|
|
@@ -1177,6 +1196,7 @@ const CLIENT_COMMANDS = {
|
|
|
1177
1196
|
mailers: cmdMailers,
|
|
1178
1197
|
feedback: cmdFeedback,
|
|
1179
1198
|
evals: cmdEvals,
|
|
1199
|
+
visits: cmdVisits,
|
|
1180
1200
|
// Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
|
|
1181
1201
|
admin: cmdAdmin,
|
|
1182
1202
|
};
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// routes, because they exist precisely to act BEFORE an org exists.
|
|
5
5
|
//
|
|
6
6
|
// admin ask "which kernels are hooked up to the most clients?" [--sql] [--json]
|
|
7
|
-
// admin patterns [--period 14d|30d] [--segment "…"] | reports [--kind reports|weekly|run|ask] | report <id> | run | ask "<question>" (fleet:read / fleet:query)
|
|
7
|
+
// admin patterns [--period 14d|30d] [--segment "…"] | kernels [--by version|family] [--outcome cited14] | reports [--kind reports|weekly|run|ask] | report <id> | run | ask "<question>" (fleet:read / fleet:query)
|
|
8
8
|
// admin experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
9
9
|
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
10
10
|
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
@@ -273,6 +273,28 @@ async function cmdAdminPatterns(ctx) {
|
|
|
273
273
|
}
|
|
274
274
|
return;
|
|
275
275
|
}
|
|
276
|
+
case "kernels": {
|
|
277
|
+
const params = new URLSearchParams();
|
|
278
|
+
if (flagStr(ctx.flags.by)) params.set("by", flagStr(ctx.flags.by));
|
|
279
|
+
if (flagStr(ctx.flags.outcome)) params.set("outcome", flagStr(ctx.flags.outcome));
|
|
280
|
+
const result = await staffRequest(ctx, "GET", `${PATTERNS}/kernels${params.size ? `?${params}` : ""}`);
|
|
281
|
+
if (json) return printResult(ctx.io, ctx.flags, result);
|
|
282
|
+
const fmt = (v) => (v == null ? "—" : `${v >= 0 ? "+" : "−"}${Math.abs(v).toFixed(3)}`);
|
|
283
|
+
ctx.io.log(
|
|
284
|
+
`Kernel changes on ${result.outcome}, by ${result.by} (effect = change minus clients that held still):`
|
|
285
|
+
);
|
|
286
|
+
for (const t of result.transitions)
|
|
287
|
+
ctx.io.log(
|
|
288
|
+
` ${`${t.from} → ${t.to}`.padEnd(30)} ${fmt(t.effect)} (${fmt(t.lo)} to ${fmt(t.hi)}) ${t.verdict} · ${t.swaps} swaps, ${t.clients} clients`
|
|
289
|
+
);
|
|
290
|
+
if (!result.transitions.length) ctx.io.log(" none yet");
|
|
291
|
+
ctx.io.log(`\n${result.by === "version" ? "Versions" : "Kernels"} (descriptive):`);
|
|
292
|
+
for (const k of result.kernels)
|
|
293
|
+
ctx.io.log(
|
|
294
|
+
` ${k.label.padEnd(16)} ${String(k.posts).padStart(6)} posts ${String(k.clients).padStart(4)} clients ${k.outcome ? k.outcome.mean.toFixed(3) : "—"}`
|
|
295
|
+
);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
276
298
|
case "reports": {
|
|
277
299
|
const kind = flagStr(ctx.flags.kind) ?? "reports";
|
|
278
300
|
const limit = flagStr(ctx.flags.limit) ?? "12";
|
|
@@ -312,7 +334,9 @@ async function cmdAdminPatterns(ctx) {
|
|
|
312
334
|
return;
|
|
313
335
|
}
|
|
314
336
|
default:
|
|
315
|
-
throw new CliError(
|
|
337
|
+
throw new CliError(
|
|
338
|
+
'Usage: admin patterns [show] | kernels | reports | report <id> | run | ask "<question>"'
|
|
339
|
+
);
|
|
316
340
|
}
|
|
317
341
|
}
|
|
318
342
|
|
|
@@ -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