@letterstory/cli 0.27.1 → 0.28.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 CHANGED
@@ -341,6 +341,16 @@ letterstory arena endpoint create --name "Docs research" --defaults '{"depth":"b
341
341
  letterstory arena endpoint config <arena-id> [--file cfg.json]
342
342
  letterstory arena endpoint delete <arena-id> --yes
343
343
 
344
+ # Ledes: a prospect's context in, ready-to-send opening lines out (chosen from a
345
+ # hand-approved library, never generated; the top choice is flagged). Synchronous.
346
+ letterstory lede create --name "Clay outbound table"
347
+ letterstory lede run <endpoint-id> --first-name Jane --company Acme --company-url acme.com --years 3
348
+ letterstory lede run <endpoint-id> --email jane@acme.com --tone direct --candidates 3
349
+ letterstory lede run <endpoint-id> --company Acme --default-lede "Came across {{company}} this week."
350
+ letterstory lede runs --limit 20
351
+ letterstory lede balance
352
+ letterstory lede endpoint config <endpoint-id> [--file cfg.json]
353
+
344
354
  # 2. Have a kernel write the draft
345
355
  letterstory kernel list
346
356
  letterstory kernel run --article <uuid> --kernel <uuid> [--brief "…" | --brief-file brief.md]
package/lib/cli.mjs CHANGED
@@ -39,12 +39,13 @@ import {
39
39
  cmdSeers,
40
40
  cmdTemplates,
41
41
  cmdShredder,
42
+ cmdLede,
42
43
  cmdLandings,
43
44
  cmdAdmin,
44
45
  } from "./commands.mjs";
45
46
 
46
47
  // Keep in sync with cli/package.json.
47
- export const VERSION = "0.27.1";
48
+ export const VERSION = "0.28.0";
48
49
 
49
50
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
50
51
  // can't accidentally swallow the id as --json's value.
@@ -538,6 +539,24 @@ Shredder (diversify content produced outside Letterstory; structure preserved):
538
539
  shredder job <job-id> Poll a queued shred and print it when it lands
539
540
  shredder runs [--endpoint <id>] [--limit <n>] Recent shred calls (telemetry), newest first
540
541
 
542
+ Ledes (opening lines for cold email, chosen per prospect from a hand-approved library):
543
+ lede list Your lede endpoints + run counts
544
+ lede create --name <n> New endpoint (returns a callable public id)
545
+ lede delete <endpoint-id> --yes
546
+ lede endpoint config <endpoint-id> [--file cfg.json] Print the endpoint's config, or replace it
547
+ lede run <endpoint-id> [--first-name <n>] [--name <full>] [--email <e>] [--title <t>]
548
+ [--company <c>] [--company-url <url>] [--school <s>] [--city <c>] [--country <c>]
549
+ [--years <n>] [--linkedin <url>] [--notes <text>]
550
+ [--candidates <1..10>] [--tone warm|direct|playful|formal] [--no-enrich]
551
+ Synchronous: prints the candidate lines with the
552
+ top choice flagged. Needs at least one of
553
+ --first-name, --name, --email, --company or
554
+ --company-url. Only a match consumes a lede;
555
+ no_match is free. --no-enrich skips the company
556
+ lookup when latency matters more than fit.
557
+ lede runs [--endpoint <id>] [--limit <n>] Recent lede calls (telemetry), newest first
558
+ lede balance Ledes left on this org
559
+
541
560
  Landings (a link + context; auto-mentioned in articles by collections you enable):
542
561
  landings list Your landing pages + mention counts
543
562
  landings get <landing-id> Show one landing page
@@ -615,6 +634,8 @@ const CLIENT_COMMANDS = {
615
634
  seers: cmdSeers,
616
635
  templates: cmdTemplates,
617
636
  shredder: cmdShredder,
637
+ lede: cmdLede,
638
+ ledes: cmdLede,
618
639
  landings: cmdLandings,
619
640
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
620
641
  admin: cmdAdmin,
@@ -0,0 +1,225 @@
1
+ // `lede` — create lede endpoints and turn a prospect's context (name, email, company,
2
+ // school, city...) into ready-to-send opening lines for cold email, chosen from a
3
+ // hand-approved library and scored by fit. A run is SYNCHRONOUS: one call returns the
4
+ // candidates in seconds (up to a minute for a company the enrichment has never seen),
5
+ // with the top choice flagged. Only a matched run consumes a lede; no_match is free.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { CliError } from "../client.mjs";
9
+ import { flagStr, requirePositional, printResult, ok, argsFromFlags } from "./shared.mjs";
10
+
11
+ /** `--<flag> <path>` → the file's parsed JSON, or undefined when the flag is not set. */
12
+ function readJsonFileFlag(flags, flag) {
13
+ const path = flagStr(flags[flag]);
14
+ if (path === undefined) return undefined;
15
+ let text;
16
+ try {
17
+ text = readFileSync(path, "utf8");
18
+ } catch (error) {
19
+ throw new CliError(
20
+ `Could not read --${flag} ${path}: ${error instanceof Error ? error.message : String(error)}`
21
+ );
22
+ }
23
+ try {
24
+ return JSON.parse(text);
25
+ } catch {
26
+ throw new CliError(`--${flag} ${path} is not valid JSON.`);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * How every command in this file sources its tool's inputs — see `argsFromFlags` in
32
+ * shared.mjs and `scripts/parity-check.ts`, which diffs these keys against each tool's
33
+ * published schema. Every lede tool is mapped: a field added to one of them and
34
+ * forgotten here fails CI.
35
+ */
36
+ export const ARGS = {
37
+ run_lede: {
38
+ endpoint_id: { positional: 0, label: "endpoint-id", required: true },
39
+ first_name: "first-name",
40
+ last_name: "last-name",
41
+ name: "name",
42
+ email: "email",
43
+ title: "title",
44
+ company: "company",
45
+ company_url: "company-url",
46
+ school: "school",
47
+ city: "city",
48
+ country: "country",
49
+ years_at_company: { flag: "years", type: "num" },
50
+ linkedin_url: "linkedin",
51
+ notes: "notes",
52
+ max_candidates: { flag: "candidates", type: "num" },
53
+ tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
54
+ // Your own fallback line, returned (unbilled, still no_match) when nothing qualifies.
55
+ default_lede: { flag: "default-lede" },
56
+ // Enrichment is ON by default server-side; `--no-enrich` skips it for latency.
57
+ enrich: { value: (flags) => (flags["no-enrich"] === true ? false : undefined) },
58
+ },
59
+ list_lede_runs: {
60
+ endpoint_id: "endpoint",
61
+ limit: { flag: "limit", type: "num" },
62
+ },
63
+ list_lede_endpoints: {},
64
+ get_lede_balance: {},
65
+ create_lede_endpoint: {
66
+ name: { flag: "name", required: true },
67
+ },
68
+ delete_lede_endpoint: {
69
+ endpoint_id: { positional: 1, label: "endpoint-id", required: true },
70
+ },
71
+ // The endpoint's configuration as ONE file: `lede endpoint config <id>` prints it,
72
+ // `lede endpoint config <id> --file cfg.json` replaces it with the file's document.
73
+ get_lede_endpoint_config: {
74
+ endpoint_id: { positional: 1, label: "endpoint-id", required: true },
75
+ },
76
+ set_lede_endpoint_config: {
77
+ endpoint_id: { positional: 1, label: "endpoint-id", required: true },
78
+ config: { value: (flags) => readJsonFileFlag(flags, "file") },
79
+ },
80
+ };
81
+
82
+ export async function cmdLede(ctx) {
83
+ const sub = ctx.positionals[0];
84
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
85
+ switch (sub) {
86
+ case "list":
87
+ case "ls":
88
+ return ledeList(rest);
89
+ case "new":
90
+ case "create":
91
+ return ledeCreate(rest);
92
+ case "delete":
93
+ case "rm":
94
+ return ledeDelete(rest);
95
+ case "endpoint":
96
+ return ledeEndpoint(rest);
97
+ case "run":
98
+ return ledeRun(rest);
99
+ case "runs":
100
+ case "telemetry":
101
+ return ledeRuns(rest);
102
+ case "balance":
103
+ return ledeBalance(rest);
104
+ default:
105
+ throw new CliError(
106
+ `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, runs, balance`
107
+ );
108
+ }
109
+ }
110
+
111
+ async function ledeList(ctx) {
112
+ const { client, flags, io } = ctx;
113
+ const result = await client.callTool("list_lede_endpoints", argsFromFlags(ARGS.list_lede_endpoints, flags));
114
+ printResult(io, flags, result);
115
+ return 0;
116
+ }
117
+
118
+ async function ledeCreate(ctx) {
119
+ const { client, flags, positionals, io } = ctx;
120
+ const args = argsFromFlags(ARGS.create_lede_endpoint, flags, positionals);
121
+ const result = await client.callTool("create_lede_endpoint", args);
122
+ ok(ctx, `Created lede endpoint "${args.name}".`);
123
+ printResult(io, flags, result);
124
+ return 0;
125
+ }
126
+
127
+ async function ledeDelete(ctx) {
128
+ const { client, positionals, flags, io } = ctx;
129
+ const id = requirePositional(positionals, 0, "endpoint-id");
130
+ if (!flags.yes) {
131
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} lede delete ${id} --yes`);
132
+ return 1;
133
+ }
134
+ // `lede delete <id>` and `lede endpoint delete <id>` are the same act; the ARGS map
135
+ // is written for the latter's positional layout.
136
+ const result = await client.callTool("delete_lede_endpoint", { endpoint_id: id });
137
+ ok(ctx, "Lede endpoint deleted.");
138
+ printResult(io, flags, result);
139
+ return 0;
140
+ }
141
+
142
+ /**
143
+ * `lede endpoint config|delete` — the endpoint's own configuration and lifecycle, the
144
+ * same shape `shredder endpoint …` and `kernel endpoint …` have, so the primitives read
145
+ * the same way.
146
+ */
147
+ async function ledeEndpoint(ctx) {
148
+ const { client, positionals, flags, io } = ctx;
149
+ const action = positionals[0];
150
+ if (action === "delete" || action === "rm") {
151
+ const id = requirePositional(positionals, 1, "endpoint-id");
152
+ if (!flags.yes) {
153
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} lede endpoint delete ${id} --yes`);
154
+ return 1;
155
+ }
156
+ const result = await client.callTool(
157
+ "delete_lede_endpoint",
158
+ argsFromFlags(ARGS.delete_lede_endpoint, flags, positionals)
159
+ );
160
+ ok(ctx, "Lede endpoint deleted.");
161
+ printResult(io, flags, result);
162
+ return 0;
163
+ }
164
+ if (action === "config") {
165
+ const id = requirePositional(positionals, 1, "endpoint-id");
166
+ if (flagStr(flags.file) === undefined) {
167
+ const result = await client.callTool(
168
+ "get_lede_endpoint_config",
169
+ argsFromFlags(ARGS.get_lede_endpoint_config, flags, positionals)
170
+ );
171
+ // The document itself, so `> cfg.json` round-trips into `--file cfg.json`.
172
+ printResult(io, flags, result, (r) => JSON.stringify(r?.config ?? r, null, 2));
173
+ return 0;
174
+ }
175
+ const args = argsFromFlags(ARGS.set_lede_endpoint_config, flags, positionals);
176
+ const result = await client.callTool("set_lede_endpoint_config", args);
177
+ ok(ctx, `Replaced the configuration of lede endpoint ${id}.`);
178
+ printResult(io, flags, result);
179
+ return 0;
180
+ }
181
+ throw new CliError(`Unknown lede endpoint action: ${action ?? "(none)"}. Try: config, delete`);
182
+ }
183
+
184
+ async function ledeRun(ctx) {
185
+ const { client, positionals, flags, io } = ctx;
186
+ const args = argsFromFlags(ARGS.run_lede, flags, positionals);
187
+ const hasAnchor = !!(args.first_name || args.name || args.email || args.company || args.company_url);
188
+ if (!hasAnchor) {
189
+ throw new CliError(
190
+ "Give the prospect at least one of --first-name, --name, --email, --company or --company-url."
191
+ );
192
+ }
193
+ const result = await client.callTool("run_lede", args);
194
+ // The operator ran this to get LINES, so print the lines: the top choice first with
195
+ // its confidence, then the rest. `--json` still gets the whole payload (enrichment,
196
+ // candidate ids for repeat-avoidance), which is what a script piping this wants.
197
+ printResult(io, flags, result, (r) => {
198
+ if (r?.status === "no_match" && !(r?.candidates ?? []).length) {
199
+ return "No line cleared the confidence floor for this person (not charged). More fields usually turn this into a match.";
200
+ }
201
+ const lines = (r?.candidates ?? []).map(
202
+ (c, i) => `${i + 1}. ${c.text} [${Math.round((c.confidence ?? 0) * 100)}%${c.top ? ", top choice" : ""}]`
203
+ );
204
+ const prefix =
205
+ r?.status === "no_match"
206
+ ? "No line cleared the confidence floor (not charged); showing your default:\n"
207
+ : "";
208
+ return prefix + (lines.join("\n") || JSON.stringify(r, null, 2));
209
+ });
210
+ return 0;
211
+ }
212
+
213
+ async function ledeRuns(ctx) {
214
+ const { client, flags, io } = ctx;
215
+ const result = await client.callTool("list_lede_runs", argsFromFlags(ARGS.list_lede_runs, flags));
216
+ printResult(io, flags, result);
217
+ return 0;
218
+ }
219
+
220
+ async function ledeBalance(ctx) {
221
+ const { client, flags, io } = ctx;
222
+ const result = await client.callTool("get_lede_balance", argsFromFlags(ARGS.get_lede_balance, flags));
223
+ printResult(io, flags, result);
224
+ return 0;
225
+ }
package/lib/commands.mjs CHANGED
@@ -23,6 +23,7 @@ export * from "./commands/arena.mjs";
23
23
  export * from "./commands/templates.mjs";
24
24
  export * from "./commands/autopen.mjs";
25
25
  export * from "./commands/kernel.mjs";
26
+ export * from "./commands/lede.mjs";
26
27
  export * from "./commands/phantom-job.mjs";
27
28
  export * from "./commands/onboard.mjs";
28
29
  export * from "./commands/seers.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",