@letterstory/cli 0.37.0 → 0.38.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
@@ -41,13 +41,16 @@ import {
41
41
  cmdShredder,
42
42
  cmdLede,
43
43
  cmdLandings,
44
+ cmdSequences,
45
+ cmdProspects,
46
+ cmdMail,
44
47
  cmdAdmin,
45
48
  cmdFeedback,
46
49
  cmdOfferings,
47
50
  } from "./commands.mjs";
48
51
 
49
52
  // Keep in sync with cli/package.json.
50
- export const VERSION = "0.37.0";
53
+ export const VERSION = "0.38.0";
51
54
 
52
55
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
53
56
  // can't accidentally swallow the id as --json's value.
@@ -101,8 +104,18 @@ const BOOLEAN_FLAGS = new Set([
101
104
  "clear-topics",
102
105
  "clear-stances",
103
106
  "clear-avoid",
107
+ // `lede run --diverse`, `--high-temp`, their `--no-*` negations and `--no-enrich`
108
+ // are value-less and can sit next to the endpoint id or another value; without
109
+ // these the parser eats the following token.
110
+ "diverse",
111
+ "no-diverse",
112
+ "high-temp",
113
+ "no-high-temp",
114
+ "no-enrich",
104
115
  // `offerings set-collection <id> --none` (the org itself is the subject again).
105
116
  "none",
117
+ // `prospects import|source --one-per-company`.
118
+ "one-per-company",
106
119
  ]);
107
120
 
108
121
  // Tiny argv parser: `--flag value`, `--flag=value`, boolean `--flag`, and positionals.
@@ -642,6 +655,8 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
642
655
  [--years <n>] [--linkedin <url>] [--notes <text>]
643
656
  [--candidates <1..10>] [--tone warm|direct|playful|formal] [--no-enrich]
644
657
  [--deadline-ms <2000..120000>]
658
+ [--default-lede <line>] [--disable-lede <id>]... [--diverse [--recent-lede <id>]...]
659
+ [--high-temp|--no-high-temp]
645
660
  Synchronous: prints the candidate lines with the
646
661
  top choice flagged. Needs at least one of
647
662
  --first-name, --name, --email, --company or
@@ -650,10 +665,39 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
650
665
  lookup when latency matters more than fit.
651
666
  --deadline-ms leaves out evidence that cannot
652
667
  arrive in time (fewer lines may qualify).
668
+ --disable-lede keeps a library line out (by id,
669
+ repeatable). --diverse gently rotates away from
670
+ recently used lines; pass the ids your sequence
671
+ already sent with --recent-lede (repeatable), or
672
+ omit them to count the endpoint's own recent runs.
673
+ --high-temp writes one strictly checked opener
674
+ (marked generated) when no library line qualifies;
675
+ the --no-* spellings override endpoint defaults.
653
676
  lede runs [--endpoint <id>] [--limit <n>] Recent lede calls (telemetry), newest first
654
677
  lede history [<domain>] [--days <n>] [--limit <n>] Openers already sent to an account
655
678
  lede evals [--sweep] Lede quality evals still above zero
656
679
  lede balance Ledes left on this org
680
+ lede library list The org's opener library: pre-built + custom lines
681
+ lede library create --text <line> [--alts <v>]... [--peer-alts <v>]... [--requires <trigger>]...
682
+ [--threshold <0..100>] [--tone warm|direct|playful|formal] [--tag <t>]...
683
+ Add a custom line. A {{placeholder}} must appear in
684
+ --requires; proprietary-engine triggers stay
685
+ reserved for the pre-built lines. --threshold
686
+ nudges the trigger's gate strictness around the
687
+ engine default: 50 = default, higher = pickier,
688
+ lower = looser.
689
+ lede library update <lede-id> [--text <line>] [--alts <v>]... [--peer-alts <v>]...
690
+ [--requires <trigger>]... [--threshold <0..100>] [--tone <t>] [--tag <t>]...
691
+ Edit a line; only the flags you pass change
692
+ lede library enable <lede-id> Make a line a run candidate again
693
+ lede library disable <lede-id> Keep a line out of runs (reversible; the way to
694
+ retire a pre-built line)
695
+ lede library delete <lede-id> --yes Delete a line (a deleted pre-built re-seeds at its
696
+ seed wording on the next list)
697
+ lede library rewrite --voice <description> [--ids <lede-id>]...
698
+ Propose brand-voice rewrites of the wording only.
699
+ Applies nothing: accept a proposal with
700
+ lede library update --text/--alts
657
701
 
658
702
  Landings (a link + context; auto-mentioned in articles by collections you enable):
659
703
  landings list Your landing pages + mention counts
@@ -663,6 +707,41 @@ Landings (a link + context; auto-mentioned in articles by collections you enable
663
707
  landings update <landing-id> [--link <url>] [--context <text>|--file <path>]
664
708
  landings delete <landing-id> --yes
665
709
 
710
+ Sequences (multi-step cold email sent from your mailers; approving or enrolling SENDS):
711
+ sequences list Your sequences + per-step funnel
712
+ sequences get <sequence-id> Flow, enrollments, who replied at each step
713
+ sequences create --name <n> (--flow '<json>'|--flow-file <path>) [--mailer <mailer-id>]...
714
+ New draft sequence (nothing sends yet)
715
+ sequences update <sequence-id> [--name <n>] [--flow '<json>'|--flow-file <path>]
716
+ [--status draft|active|paused|archived] [--mailer <mailer-id>]...
717
+ sequences pause|resume <sequence-id> Stop / restart sending
718
+ sequences enroll <sequence-id> (--list <list-id> | --email <addr>... | --recipients '<json>'|--recipients-file <path>)
719
+ sequences stop <sequence-id> <enrollment-id> Stop one recipient
720
+ sequences review list Emails waiting for a decision
721
+ sequences review edit <item-id> [--subject <s>] [--body <text>|--file <path>]
722
+ sequences review approve|skip|delete <item-id> Decide one (approve sends now)
723
+ sequences links list Live Pocket Dispatch links (no tokens)
724
+ sequences links create --days <1..3> Mint a no-login review link (shown once)
725
+ sequences links revoke <link-id> --yes
726
+
727
+ Prospects (who you write to):
728
+ prospects lists Your lists + counts
729
+ prospects list <list-id> One list
730
+ prospects ls [--list <list-id>] [--status <s>] [--q <search>] [--limit <n>]
731
+ prospects get <prospect-id>
732
+ prospects create-list --name <n>
733
+ prospects import --csv-file <path|-> [--list <list-id>|--list-name <n>] [--one-per-company]
734
+ prospects source --description <who you want> [--list <list-id>|--list-name <n>] [--limit <1..100>]
735
+ [--one-per-company] Fill a list from paid data providers (background)
736
+ prospects enrich [--list <list-id>] [--status <s>] [--q <search>]
737
+ Funding + competitors for un-enriched rows
738
+ prospects update <prospect-id> [--status new|contacted|replied|unsubscribed|archived] [--name <n>]
739
+ [--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
740
+ [--city] [--country] [--years] [--notes] [--custom key=value]...
741
+
742
+ Mail:
743
+ mail connections Connected mailboxes, Google connect + Vercel key status
744
+
666
745
  Insights:
667
746
  insights site [--period 14d|30d|90d] [--collection <uuid>]
668
747
  insights post <article-id> [--period 14d|30d|90d]
@@ -740,6 +819,9 @@ const CLIENT_COMMANDS = {
740
819
  lede: cmdLede,
741
820
  ledes: cmdLede,
742
821
  landings: cmdLandings,
822
+ sequences: cmdSequences,
823
+ prospects: cmdProspects,
824
+ mail: cmdMail,
743
825
  feedback: cmdFeedback,
744
826
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
745
827
  admin: cmdAdmin,
@@ -59,6 +59,15 @@ export const ARGS = {
59
59
  enrich: { value: (flags) => (flags["no-enrich"] === true ? false : undefined) },
60
60
  // A latency budget: optional evidence that cannot arrive in time is left out.
61
61
  deadline_ms: { flag: "deadline-ms", type: "num" },
62
+ // Advanced: keep specific library lines out of this run (repeatable, by line id).
63
+ disabled_ledes: { flag: "disable-lede", type: "list" },
64
+ // Gently rotate away from openers this endpoint (or --recent-lede) already used.
65
+ // The negate spelling exists so a call can override an endpoint default to OFF.
66
+ optimize_diversity: { flag: "diverse", type: "bool", negate: "no-diverse" },
67
+ // The line ids this sequence already used (repeatable); counted by --diverse.
68
+ recent_ledes: { flag: "recent-lede", type: "list" },
69
+ // When no library line qualifies, WRITE one (strictly checked, marked generated).
70
+ high_temperature: { flag: "high-temp", type: "bool", negate: "no-high-temp" },
62
71
  },
63
72
  list_lede_runs: {
64
73
  endpoint_id: "endpoint",
@@ -108,6 +117,42 @@ export const ARGS = {
108
117
  endpoint_id: { positional: 1, label: "endpoint-id", required: true },
109
118
  config: { value: (flags) => readJsonFileFlag(flags, "file") },
110
119
  },
120
+ // The editable library (`lede library ...`): the org's own opening lines, pre-built
121
+ // and custom. Reads seed the pre-built set idempotently; writes are validated server-
122
+ // side (known triggers, placeholder coverage, the custom-line engine ban).
123
+ list_ledes: {},
124
+ create_lede: {
125
+ text: { flag: "text", required: true },
126
+ alts: { flag: "alts", type: "list" },
127
+ peer_alts: { flag: "peer-alts", type: "list" },
128
+ requires: { flag: "requires", type: "list" },
129
+ // 0-100 strictness around the engine default (50): higher = pickier, lower = looser.
130
+ threshold: { flag: "threshold", type: "num" },
131
+ tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
132
+ tags: { flag: "tag", type: "list" },
133
+ },
134
+ update_lede: {
135
+ id: { positional: 0, label: "lede-id", required: true },
136
+ text: "text",
137
+ alts: { flag: "alts", type: "list" },
138
+ peer_alts: { flag: "peer-alts", type: "list" },
139
+ requires: { flag: "requires", type: "list" },
140
+ threshold: { flag: "threshold", type: "num" },
141
+ tone: { flag: "tone", enum: ["warm", "direct", "playful", "formal"] },
142
+ tags: { flag: "tag", type: "list" },
143
+ },
144
+ set_lede_enabled: {
145
+ id: { positional: 0, label: "lede-id", required: true },
146
+ // `lede library enable|disable <id>` decides this; the subcommand passes it in.
147
+ enabled: { value: (_flags, _positionals, extra) => extra.enabled },
148
+ },
149
+ delete_lede: {
150
+ id: { positional: 0, label: "lede-id", required: true },
151
+ },
152
+ rewrite_ledes_voice: {
153
+ voice: { flag: "voice", required: true },
154
+ ids: { flag: "ids", type: "list" },
155
+ },
111
156
  };
112
157
 
113
158
  export async function cmdLede(ctx) {
@@ -125,6 +170,9 @@ export async function cmdLede(ctx) {
125
170
  return ledeDelete(rest);
126
171
  case "endpoint":
127
172
  return ledeEndpoint(rest);
173
+ case "library":
174
+ case "lib":
175
+ return ledeLibrary(rest);
128
176
  case "run":
129
177
  return ledeRun(rest);
130
178
  case "runs":
@@ -145,7 +193,103 @@ export async function cmdLede(ctx) {
145
193
  return ledeFunnel(rest);
146
194
  default:
147
195
  throw new CliError(
148
- `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, run, runs, history, evals, balance, outcome, funnel`
196
+ `Unknown lede subcommand: ${sub ?? "(none)"}. Try: list, create, delete, endpoint, library, run, runs, history, evals, balance, outcome, funnel`
197
+ );
198
+ }
199
+ }
200
+
201
+ /**
202
+ * `lede library list|create|update|enable|disable|delete|rewrite` — the org's own opener
203
+ * library (pre-built lines seeded on first read, plus lines the org authors). Runs pick
204
+ * only from enabled lines; `rewrite` only PROPOSES new wordings, and an accepted proposal
205
+ * is applied with `lede library update`.
206
+ */
207
+ async function ledeLibrary(ctx) {
208
+ const { client, positionals, flags, io } = ctx;
209
+ const action = positionals[0];
210
+ const rest = { ...ctx, positionals: positionals.slice(1) };
211
+ switch (action) {
212
+ case "list":
213
+ case "ls": {
214
+ const result = await client.callTool("list_ledes", argsFromFlags(ARGS.list_ledes, flags));
215
+ // One line per row: state, id, provenance, then the wording. `--json` for the rest
216
+ // (alts, requires, threshold, tags) when editing.
217
+ printResult(io, flags, result, (r) =>
218
+ (r?.ledes ?? [])
219
+ .map(
220
+ (l) =>
221
+ `${l.enabled ? "on " : "off"} ${l.id} [${l.source}${l.engine ? `/${l.engine}` : ""}] ${l.text}`
222
+ )
223
+ .join("\n")
224
+ );
225
+ return 0;
226
+ }
227
+ case "create":
228
+ case "new":
229
+ case "add": {
230
+ const args = argsFromFlags(ARGS.create_lede, flags, rest.positionals);
231
+ const result = await client.callTool("create_lede", args);
232
+ ok(ctx, "Added the line to the library.");
233
+ printResult(io, flags, result);
234
+ return 0;
235
+ }
236
+ case "update":
237
+ case "edit": {
238
+ const args = argsFromFlags(ARGS.update_lede, flags, rest.positionals);
239
+ const result = await client.callTool("update_lede", args);
240
+ ok(ctx, `Updated line ${args.id}.`);
241
+ printResult(io, flags, result);
242
+ return 0;
243
+ }
244
+ case "enable":
245
+ case "disable": {
246
+ const enabled = action === "enable";
247
+ const args = argsFromFlags(ARGS.set_lede_enabled, flags, rest.positionals, { enabled });
248
+ const result = await client.callTool("set_lede_enabled", args);
249
+ ok(ctx, `${enabled ? "Enabled" : "Disabled"} line ${args.id}.`);
250
+ printResult(io, flags, result);
251
+ return 0;
252
+ }
253
+ case "delete":
254
+ case "rm": {
255
+ const id = requirePositional(rest.positionals, 0, "lede-id");
256
+ if (!flags.yes) {
257
+ io.error(`Re-run with --yes to confirm: ${ctx.bin} lede library delete ${id} --yes`);
258
+ return 1;
259
+ }
260
+ const result = await client.callTool(
261
+ "delete_lede",
262
+ argsFromFlags(ARGS.delete_lede, flags, rest.positionals)
263
+ );
264
+ ok(
265
+ ctx,
266
+ "Line deleted. A pre-built line re-seeds at its seed wording on the next list; disable instead to keep one out."
267
+ );
268
+ printResult(io, flags, result);
269
+ return 0;
270
+ }
271
+ case "rewrite": {
272
+ const args = argsFromFlags(ARGS.rewrite_ledes_voice, flags, rest.positionals);
273
+ const result = await client.callTool("rewrite_ledes_voice", args);
274
+ // Nothing is applied: print the proposals for a human to vouch for, with the
275
+ // command that applies an accepted one.
276
+ printResult(io, flags, result, (r) => {
277
+ const proposals = (r?.proposals ?? []).map((p) => `${p.id}\n ${p.text}`);
278
+ const rejected = (r?.rejected ?? []).map((x) => `${x.id}: ${(x.reasons ?? []).join("; ")}`);
279
+ const parts = [];
280
+ parts.push(
281
+ proposals.length
282
+ ? `Proposed rewrites (nothing applied; accept one with: ${ctx.bin} lede library update <lede-id> --text "..."):\n${proposals.join("\n")}`
283
+ : "No rewrite cleared the checks; the originals stand."
284
+ );
285
+ if (rejected.length) parts.push(`Rejected (original stands):\n${rejected.join("\n")}`);
286
+ return parts.join("\n\n");
287
+ });
288
+ return 0;
289
+ }
290
+ default:
291
+ throw new CliError(
292
+ `Unknown lede library action: ${action ?? "(none)"}. Try: list, create, update, enable, disable, delete, rewrite`
149
293
  );
150
294
  }
151
295
  }
@@ -257,7 +401,8 @@ async function ledeRun(ctx) {
257
401
  return "No line cleared the confidence floor for this person (not charged). More fields usually turn this into a match.";
258
402
  }
259
403
  const lines = (r?.candidates ?? []).map(
260
- (c, i) => `${i + 1}. ${c.text} [${Math.round((c.confidence ?? 0) * 100)}%${c.top ? ", top choice" : ""}]`
404
+ (c, i) =>
405
+ `${i + 1}. ${c.text} [${Math.round((c.confidence ?? 0) * 100)}%${c.top ? ", top choice" : ""}${c.generated ? ", written for this prospect" : ""}]`
261
406
  );
262
407
  const prefix =
263
408
  r?.status === "no_match"
@@ -0,0 +1,324 @@
1
+ // `sequences`, `prospects` and `mail` — the outbound email stack, as thin wrappers
2
+ // over the MCP tools, which call the same lib functions the app's /api/sequences
3
+ // and /api/prospects routes do. Every call is built from the ARGS map below, so
4
+ // `scripts/parity-check.ts` can hold each command to its tool's input schema.
5
+
6
+ import { CliError } from "../client.mjs";
7
+ import {
8
+ flagStr,
9
+ flagList,
10
+ requirePositional,
11
+ printResult,
12
+ argsFromFlags,
13
+ refuseUnknownFlags,
14
+ readTextInput,
15
+ ok,
16
+ } from "./shared.mjs";
17
+
18
+ // A JSON document from --<name> '<json>' or --<name>-file <path|->.
19
+ function jsonInput(flag) {
20
+ return (flags) => {
21
+ const raw = readTextInput(flags, flag, `${flag}-file`);
22
+ if (raw === undefined) return undefined;
23
+ try {
24
+ return JSON.parse(raw);
25
+ } catch (err) {
26
+ throw new CliError(`--${flag} is not valid JSON: ${err.message}`);
27
+ }
28
+ };
29
+ }
30
+
31
+ // Recipients from repeatable --email <addr>, or a JSON array via --recipients/--recipients-file.
32
+ function readRecipients(flags) {
33
+ const fromJson = jsonInput("recipients")(flags);
34
+ if (fromJson !== undefined) return fromJson;
35
+ const emails = flagList(flags.email);
36
+ return emails.length ? emails.map((email) => ({ email })) : undefined;
37
+ }
38
+
39
+ // `--custom key=value` (repeatable) → { key: value }.
40
+ function readCustom(flags) {
41
+ const entries = flagList(flags.custom);
42
+ if (!entries.length) return undefined;
43
+ const out = {};
44
+ for (const entry of entries) {
45
+ const i = entry.indexOf("=");
46
+ if (i <= 0) throw new CliError(`--custom takes key=value (got ${entry})`);
47
+ out[entry.slice(0, i)] = entry.slice(i + 1);
48
+ }
49
+ return out;
50
+ }
51
+
52
+ const SEQUENCE_STATUSES = ["draft", "active", "paused", "archived"];
53
+ const PROSPECT_STATUSES = ["new", "contacted", "replied", "unsubscribed", "archived"];
54
+ const mailerIds = {
55
+ value: (flags) => {
56
+ const ids = flagList(flags.mailer);
57
+ return ids.length ? ids : undefined;
58
+ },
59
+ };
60
+
61
+ // How every tool this module calls gets its arguments — see `argsFromFlags` in shared.mjs.
62
+ export const ARGS = {
63
+ list_sequences: {},
64
+ get_sequence: {
65
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
66
+ },
67
+ create_sequence: {
68
+ name: { flag: "name", required: true },
69
+ flow: {
70
+ value: (flags) => {
71
+ const flow = jsonInput("flow")(flags);
72
+ if (flow === undefined) throw new CliError("Missing required --flow '<json>' (or --flow-file <path>)");
73
+ return flow;
74
+ },
75
+ },
76
+ mailer_ids: mailerIds,
77
+ },
78
+ update_sequence: {
79
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
80
+ name: "name",
81
+ flow: { value: jsonInput("flow") },
82
+ status: { flag: "status", enum: SEQUENCE_STATUSES },
83
+ mailer_ids: mailerIds,
84
+ },
85
+ enroll_in_sequence: {
86
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
87
+ recipients: { value: readRecipients },
88
+ list_id: "list",
89
+ },
90
+ stop_sequence_enrollment: {
91
+ sequence_id: { positional: 0, label: "sequence-id", required: true },
92
+ enrollment_id: { positional: 1, label: "enrollment-id", required: true },
93
+ },
94
+ list_review_items: {},
95
+ update_review_item: {
96
+ item_id: { positional: 0, label: "item-id", required: true },
97
+ subject: "subject",
98
+ body: { value: (flags) => readTextInput(flags, "body", "file") },
99
+ },
100
+ decide_review_item: {
101
+ item_id: { positional: 0, label: "item-id", required: true },
102
+ action: { flag: "action", required: true, enum: ["approve", "skip", "delete"] },
103
+ },
104
+ list_dispatch_links: {},
105
+ create_dispatch_link: {
106
+ days: { flag: "days", type: "num", required: true },
107
+ },
108
+ revoke_dispatch_link: {
109
+ link_id: { positional: 0, label: "link-id", required: true },
110
+ },
111
+ list_prospect_lists: {},
112
+ get_prospect_list: {
113
+ list_id: { positional: 0, label: "list-id", required: true },
114
+ },
115
+ list_prospects: {
116
+ list_id: "list",
117
+ status: { flag: "status", enum: PROSPECT_STATUSES },
118
+ q: "q",
119
+ limit: { flag: "limit", type: "num" },
120
+ },
121
+ get_prospect: {
122
+ prospect_id: { positional: 0, label: "prospect-id", required: true },
123
+ },
124
+ create_prospect_list: {
125
+ name: { flag: "name", required: true },
126
+ },
127
+ import_prospects_csv: {
128
+ csv_text: {
129
+ value: (flags) => {
130
+ const csv = readTextInput(flags, "csv", "csv-file");
131
+ if (csv === undefined)
132
+ throw new CliError("Missing CSV: pass --csv-file <path> (or --csv-file - for stdin)");
133
+ return csv;
134
+ },
135
+ },
136
+ one_per_company: { flag: "one-per-company", type: "bool" },
137
+ list_id: "list",
138
+ list_name: "list-name",
139
+ },
140
+ create_prospect_list_from_prompt: {
141
+ description: { flag: "description", required: true },
142
+ list_id: "list",
143
+ list_name: "list-name",
144
+ limit: { flag: "limit", type: "num" },
145
+ one_per_company: { flag: "one-per-company", type: "bool" },
146
+ },
147
+ enrich_prospects: {
148
+ list_id: "list",
149
+ status: { flag: "status", enum: PROSPECT_STATUSES },
150
+ q: "q",
151
+ },
152
+ update_prospect: {
153
+ prospect_id: { positional: 0, label: "prospect-id", required: true },
154
+ status: { flag: "status", enum: PROSPECT_STATUSES },
155
+ name: "name",
156
+ first_name: "first-name",
157
+ last_name: "last-name",
158
+ title: "title",
159
+ company: "company",
160
+ website: "website",
161
+ linkedin_url: "linkedin",
162
+ school: "school",
163
+ city: "city",
164
+ country: "country",
165
+ years_at_company: "years",
166
+ notes: "notes",
167
+ custom: { value: readCustom },
168
+ },
169
+ get_mail_connections: {},
170
+ };
171
+
172
+ // Flags the computed `value` sources read (refuseUnknownFlags can't see them).
173
+ const VALUE_FLAGS = {
174
+ create_sequence: ["flow", "flow-file", "mailer"],
175
+ update_sequence: ["flow", "flow-file", "mailer"],
176
+ enroll_in_sequence: ["recipients", "recipients-file", "email"],
177
+ update_review_item: ["body", "file"],
178
+ import_prospects_csv: ["csv", "csv-file"],
179
+ update_prospect: ["custom"],
180
+ };
181
+
182
+ async function run(ctx, tool, message) {
183
+ refuseUnknownFlags(ctx, ARGS[tool], VALUE_FLAGS[tool] ?? []);
184
+ const result = await ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
185
+ if (message) ok(ctx, message);
186
+ printResult(ctx.io, ctx.flags, result);
187
+ return 0;
188
+ }
189
+
190
+ const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links>`;
191
+
192
+ export async function cmdSequences(ctx) {
193
+ const sub = ctx.positionals[0];
194
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
195
+ switch (sub) {
196
+ case "list":
197
+ case "ls":
198
+ return run(rest, "list_sequences");
199
+ case "get":
200
+ case "show":
201
+ return run(rest, "get_sequence");
202
+ case "create":
203
+ case "new":
204
+ return run(rest, "create_sequence", "Created sequence (a draft: set --status active to send).");
205
+ case "update":
206
+ return run(rest, "update_sequence", "Updated sequence.");
207
+ case "pause":
208
+ case "resume": {
209
+ const id = requirePositional(rest.positionals, 0, "sequence-id");
210
+ const status = sub === "pause" ? "paused" : "active";
211
+ const result = await ctx.client.callTool(
212
+ "update_sequence",
213
+ argsFromFlags(ARGS.update_sequence, { status }, [id])
214
+ );
215
+ ok(ctx, sub === "pause" ? "Paused: nothing further sends." : "Resumed: sending again.");
216
+ printResult(ctx.io, ctx.flags, result);
217
+ return 0;
218
+ }
219
+ case "enroll":
220
+ return run(rest, "enroll_in_sequence", "Enrolled.");
221
+ case "stop":
222
+ return run(rest, "stop_sequence_enrollment", "Enrollment stopped.");
223
+ case "review":
224
+ return sequencesReview({ ...rest, positionals: rest.positionals.slice(1) }, rest.positionals[0]);
225
+ case "links":
226
+ return sequencesLinks({ ...rest, positionals: rest.positionals.slice(1) }, rest.positionals[0]);
227
+ default:
228
+ throw new CliError(`Unknown sequences subcommand: ${sub ?? "(none)"}. ${SEQUENCES_USAGE}`);
229
+ }
230
+ }
231
+
232
+ async function sequencesReview(ctx, sub) {
233
+ switch (sub) {
234
+ case "list":
235
+ case "ls":
236
+ case undefined:
237
+ return run(ctx, "list_review_items");
238
+ case "edit":
239
+ return run(ctx, "update_review_item", "Saved the edit.");
240
+ case "decide":
241
+ return run(ctx, "decide_review_item", "Decided.");
242
+ case "approve":
243
+ case "skip":
244
+ case "delete": {
245
+ const id = requirePositional(ctx.positionals, 0, "item-id");
246
+ const result = await ctx.client.callTool(
247
+ "decide_review_item",
248
+ argsFromFlags(ARGS.decide_review_item, { action: sub }, [id])
249
+ );
250
+ ok(ctx, sub === "approve" ? "Approved: sending now." : sub === "skip" ? "Skipped." : "Deleted.");
251
+ printResult(ctx.io, ctx.flags, result);
252
+ return 0;
253
+ }
254
+ default:
255
+ throw new CliError(`Unknown review subcommand: ${sub}. Try: list, edit, decide, approve, skip, delete`);
256
+ }
257
+ }
258
+
259
+ async function sequencesLinks(ctx, sub) {
260
+ switch (sub) {
261
+ case "list":
262
+ case "ls":
263
+ case undefined:
264
+ return run(ctx, "list_dispatch_links");
265
+ case "create":
266
+ return run(ctx, "create_dispatch_link", "Link minted. Anyone holding it can send; it is shown only once.");
267
+ case "revoke": {
268
+ if (!ctx.flags.yes) {
269
+ const id = flagStr(ctx.positionals[0]) ?? "<link-id>";
270
+ ctx.io.error(`Re-run with --yes to confirm: ${ctx.bin} sequences links revoke ${id} --yes`);
271
+ return 1;
272
+ }
273
+ return run(ctx, "revoke_dispatch_link", "Link revoked.");
274
+ }
275
+ default:
276
+ throw new CliError(`Unknown links subcommand: ${sub}. Try: list, create, revoke`);
277
+ }
278
+ }
279
+
280
+ const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|import|source|enrich|update>`;
281
+
282
+ export async function cmdProspects(ctx) {
283
+ const sub = ctx.positionals[0];
284
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
285
+ switch (sub) {
286
+ case "lists":
287
+ return run(rest, "list_prospect_lists");
288
+ case "list":
289
+ return run(rest, "get_prospect_list");
290
+ case "ls":
291
+ return run(rest, "list_prospects");
292
+ case "get":
293
+ case "show":
294
+ return run(rest, "get_prospect");
295
+ case "create-list":
296
+ return run(rest, "create_prospect_list", "List ready.");
297
+ case "import":
298
+ return run(rest, "import_prospects_csv", "Imported.");
299
+ case "source":
300
+ return run(
301
+ rest,
302
+ "create_prospect_list_from_prompt",
303
+ "Sourcing started; rows land in the list as they arrive."
304
+ );
305
+ case "enrich":
306
+ return run(rest, "enrich_prospects", "Enrichment queued.");
307
+ case "update":
308
+ return run(rest, "update_prospect", "Updated prospect.");
309
+ default:
310
+ throw new CliError(`Unknown prospects subcommand: ${sub ?? "(none)"}. ${PROSPECTS_USAGE}`);
311
+ }
312
+ }
313
+
314
+ export async function cmdMail(ctx) {
315
+ const sub = ctx.positionals[0];
316
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
317
+ switch (sub) {
318
+ case "connections":
319
+ case "status":
320
+ return run(rest, "get_mail_connections");
321
+ default:
322
+ throw new CliError(`Unknown mail subcommand: ${sub ?? "(none)"}. Try: connections`);
323
+ }
324
+ }
package/lib/commands.mjs CHANGED
@@ -32,6 +32,7 @@ export * from "./commands/onboard.mjs";
32
32
  export * from "./commands/seers.mjs";
33
33
  export * from "./commands/shredder.mjs";
34
34
  export * from "./commands/landings.mjs";
35
+ export * from "./commands/outbound.mjs";
35
36
  export * from "./commands/offerings.mjs";
36
37
  export * from "./commands/admin.mjs";
37
38
  export * from "./commands/feedback.mjs";
package/lib/documents.mjs CHANGED
@@ -77,6 +77,8 @@ export const JSON_ARG_FLAG_PAIRS = [
77
77
  ["plan", "plan-file"],
78
78
  // A reviewed plan handed back to be applied verbatim (onboard.mjs readAcceptedPlan).
79
79
  ["accept-plan-json", "accept-plan"],
80
+ // A prospects CSV, parsed and header-mapped server-side (outbound.mjs prospects import).
81
+ ["csv", "csv-file"],
80
82
  ];
81
83
 
82
84
  /** Lower-case extension without the dot. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",