@letterstory/cli 0.64.0 → 0.66.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
@@ -777,10 +777,17 @@ letterstory sequences list | get <id> | create | update <id> | pause <id> | resu
777
777
  letterstory sequences enroll <id> --list <list-id> # or --email a@x.com ...
778
778
  letterstory sequences review list | approve <item-id> | skip <item-id> | edit <item-id> --subject "…"
779
779
  letterstory sequences links create --days 2 # no-login phone review link, shown once
780
+ letterstory sequences update <id> --test-mode # internal accounts only: walks as live, sends nothing
781
+
782
+ # Dispatches (the review desks a sequence queues to)
783
+ letterstory dispatches list | create --name "Founders" | rename <id> --name "VIPs" | delete <id> --yes
780
784
 
781
785
  # Prospects
782
786
  letterstory prospects lists | ls --list <list-id> | get <id> | import --csv-file leads.csv --list-name "Q4"
783
787
  letterstory prospects source --description "heads of growth at seed-stage fintech" --list-name "Fintech"
788
+ letterstory prospects openers <list-id> --new-group "Fintech openers" # print an opening line per row
789
+ letterstory prospects columns add "Cake image" | columns delete <column-id> --yes
790
+ letterstory prospects delete <prospect-id>... --yes
784
791
  ```
785
792
 
786
793
  Connecting a bring-your-own Gmail mailbox needs a browser (Google consent), so it is done in the
package/lib/cli.mjs CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  cmdLede,
45
45
  cmdLandings,
46
46
  cmdSequences,
47
+ cmdDispatches,
47
48
  cmdProspects,
48
49
  cmdMail,
49
50
  cmdMailers,
@@ -55,7 +56,7 @@ import {
55
56
  } from "./commands.mjs";
56
57
 
57
58
  // Keep in sync with cli/package.json.
58
- export const VERSION = "0.64.0";
59
+ export const VERSION = "0.66.0";
59
60
 
60
61
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
61
62
  // can't accidentally swallow the id as --json's value.
@@ -549,6 +550,13 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
549
550
  read-only SQL across every org; --sql prints
550
551
  the queries behind the answer. Needs the
551
552
  fleet:query capability on the staff key.
553
+ admin replays [--org <uuid>] [--target <uuid>] [--status captured|not_reproduced|queued|running|unsupported|failed] [--limit <n>]
554
+ Live answer replays: breakthroughs re-asked in ChatGPT /
555
+ Gemini / Google / Perplexity / Claude, with screenshots.
556
+ Omit --org for the whole fleet. Needs fleet:read.
557
+ admin replay --org <uuid> (<response-id> | --target <uuid>) [--surface openai|anthropic|google-aio|google-gemini|perplexity]
558
+ Replay a breakthrough live now (background; poll
559
+ admin replays). Needs fleet:replay.
552
560
  admin experiments list [--q colons] [--status proposed] | show <id>
553
561
  | new "<question>" [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
554
562
  | update <id> [--plan-file …] [--result-file …] [--evidence kind:ref,…] [--budget N]
@@ -808,13 +816,14 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
808
816
  sequences get <sequence-id> Flow, enrollments, who replied at each step
809
817
  sequences create --name <n> (--flow '<json>'|--flow-file <path>) [--mailer <mailer-id>]...
810
818
  [--[no-]opt-out-footer] [--[no-]postal-address-footer] [--[no-]unsubscribe-links] [--[no-]detect-reply-opt-outs]
811
- [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
819
+ [--[no-]test-mode] [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
812
820
  New draft sequence (nothing sends yet; rails default on, opt-out line off;
813
821
  sends 9:00-17:00 in each recipient's inferred timezone, else the sender's)
814
822
  sequences update <sequence-id> [--name <n>] [--flow '<json>'|--flow-file <path>]
815
823
  [--status draft|active|paused|archived] [--mailer <mailer-id>]...
816
824
  [--[no-]opt-out-footer] [--[no-]postal-address-footer] [--[no-]unsubscribe-links] [--[no-]detect-reply-opt-outs]
817
- [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
825
+ [--[no-]test-mode] [--send-window-start <HH:MM>] [--send-window-end <HH:MM>] [--send-window-timezone <IANA tz>]
826
+ --test-mode (internal accounts only): walks as live, sends nothing
818
827
  sequences pause|resume <sequence-id> Stop / restart sending
819
828
  sequences enroll <sequence-id> (--list <list-id> | --email <addr>... | --recipients '<json>'|--recipients-file <path>)
820
829
  sequences stop <sequence-id> <enrollment-id> Stop one recipient
@@ -825,6 +834,12 @@ Sequences (multi-step cold email sent from your mailers; approving or enrolling
825
834
  sequences links create --days <1..3> Mint a no-login review link (shown once)
826
835
  sequences links revoke <link-id> --yes
827
836
 
837
+ Dispatches (the named review desks a sequence's review stage queues to):
838
+ dispatches list Desks + how many emails each has queued
839
+ dispatches create --name <n>
840
+ dispatches rename <dispatch-id> --name <n>
841
+ dispatches delete <dispatch-id> --yes Queued emails move to the default desk
842
+
828
843
  Prospects (who you write to):
829
844
  prospects lists Your lists + counts
830
845
  prospects list <list-id> One list
@@ -837,6 +852,11 @@ Prospects (who you write to):
837
852
  --enrich starts enrichment as soon as the pull lands
838
853
  prospects enrich [--list <list-id>] [--status <s>] [--q <search>]
839
854
  Funding + competitors for un-enriched rows
855
+ prospects openers <list-id> [--group <ledes-group-id>|--new-group <name>]
856
+ Print an opening line for every row without one (background)
857
+ prospects delete <prospect-id>... --yes Permanently delete prospects
858
+ prospects columns add <label> Add a custom column (key returned)
859
+ prospects columns delete <column-id> --yes
840
860
  prospects update <prospect-id> [--status new|contacted|replied|unsubscribed|archived] [--name <n>]
841
861
  [--first-name] [--last-name] [--title] [--company] [--website] [--linkedin] [--school]
842
862
  [--city] [--country] [--years] [--notes] [--custom key=value]...
@@ -960,6 +980,7 @@ const CLIENT_COMMANDS = {
960
980
  ledes: cmdLede,
961
981
  landings: cmdLandings,
962
982
  sequences: cmdSequences,
983
+ dispatches: cmdDispatches,
963
984
  prospects: cmdProspects,
964
985
  mail: cmdMail,
965
986
  mailers: cmdMailers,
@@ -10,6 +10,8 @@
10
10
  // admin tuning kernels|kernel|candidate|compare|comparisons|comparison|picks|pick|promote … (tuning:experiment)
11
11
  // admin evals scoreboard|cases|runs|run|triage|guard|retire … (fleet:read / tuning:experiment)
12
12
  // admin crm import|capture|signals|record|signal|accounts|account|score|slack-channels|link-slack|white-glove|set-white-glove|churn|record-churn|undo-churn|onboard|onboarding|retry-onboarding|onboard-plan|payment-link … (crm:read / crm:write)
13
+ // admin replays [--org <id>] [--target <id>] [--status …] [--limit n] (fleet:read)
14
+ // admin replay --org <id> (<response-id> | --target <id>) [--surface …] (fleet:replay)
13
15
  // admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
14
16
  // [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
15
17
  // [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
@@ -1926,8 +1928,80 @@ async function cmdAdminEvals(ctx) {
1926
1928
  }
1927
1929
  }
1928
1930
 
1931
+ const REPLAY_SURFACES = ["openai", "anthropic", "google-aio", "google-gemini", "perplexity"];
1932
+ const REPLAY_STATUSES = ["queued", "running", "captured", "not_reproduced", "unsupported", "failed"];
1933
+ const REPLAYS = "/api/superadmin/answer-replays";
1934
+
1935
+ function oneOf(flags, name, values) {
1936
+ const v = flagStr(flags[name]);
1937
+ if (v !== undefined && !values.includes(v)) throw new CliError(`--${name} must be one of: ${values.join(", ")}`);
1938
+ return v;
1939
+ }
1940
+
1941
+ function formatReplays(r) {
1942
+ const rows = r.replays ?? [];
1943
+ if (!rows.length) return "No answer replays.";
1944
+ return rows
1945
+ .map((x) => {
1946
+ const head = `${x.id} ${x.surfaceLabel} ${x.status} ${x.attemptsMade}/${x.plannedAttempts} asks ${x.createdAt}`;
1947
+ const lines = [head];
1948
+ if (x.capture) {
1949
+ lines.push(
1950
+ ` captured on ask ${x.capture.step + 1}: "${x.capture.query}" (web search ${x.capture.webSearch})`
1951
+ );
1952
+ for (const img of x.capture.images ?? []) lines.push(` ${img.kind}: ${img.url}`);
1953
+ }
1954
+ if (x.error) lines.push(` ${x.error}`);
1955
+ return lines.join("\n");
1956
+ })
1957
+ .join("\n");
1958
+ }
1959
+
1960
+ /** `admin replays` / `admin replay` — live answer replays (staff only). */
1961
+ async function cmdAdminReplays(ctx, sub) {
1962
+ const { flags, positionals, io } = ctx;
1963
+ if (sub === "replays") {
1964
+ const params = new URLSearchParams(
1965
+ compact({
1966
+ org_id: flagStr(flags.org),
1967
+ target_id: flagStr(flags.target),
1968
+ status: oneOf(flags, "status", REPLAY_STATUSES),
1969
+ limit: flagNum(flags.limit)?.toString(),
1970
+ })
1971
+ );
1972
+ const result = await staffRequest(ctx, "GET", `${REPLAYS}${params.size ? `?${params}` : ""}`);
1973
+ printResult(io, flags, result, formatReplays);
1974
+ return 0;
1975
+ }
1976
+ const org = flagStr(flags.org);
1977
+ const responseId = positionals[0];
1978
+ const target = flagStr(flags.target);
1979
+ if (!org) throw new CliError("admin replay needs --org <org-id>");
1980
+ if (!responseId && !target) throw new CliError("Pass a breakthrough <response-id>, or --target <target-id>");
1981
+ const result = await staffPost(
1982
+ ctx,
1983
+ REPLAYS,
1984
+ compact({
1985
+ org_id: org,
1986
+ response_id: responseId,
1987
+ target_id: target,
1988
+ surface: oneOf(flags, "surface", REPLAY_SURFACES),
1989
+ })
1990
+ );
1991
+ ok(
1992
+ ctx,
1993
+ result.already_queued
1994
+ ? `Already replaying: ${result.replay_id}`
1995
+ : `Replay ${result.replay_id} is ${result.status}. Poll: ${ctx.bin} admin replays --org ${org}`
1996
+ );
1997
+ printResult(io, flags, result);
1998
+ return 0;
1999
+ }
2000
+
1929
2001
  export async function cmdAdmin(ctx) {
1930
2002
  const sub = ctx.positionals[0];
2003
+ if (sub === "replays" || sub === "replay")
2004
+ return cmdAdminReplays({ ...ctx, positionals: ctx.positionals.slice(1) }, sub);
1931
2005
  if (sub === "crm") return cmdAdminCrm({ ...ctx, positionals: ctx.positionals.slice(1) });
1932
2006
  if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
1933
2007
  if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
@@ -1937,6 +2011,6 @@ export async function cmdAdmin(ctx) {
1937
2011
  if (sub === "tuning") return cmdAdminTuning({ ...ctx, positionals: ctx.positionals.slice(1) });
1938
2012
  if (sub === "evals") return cmdAdminEvals({ ...ctx, positionals: ctx.positionals.slice(1) });
1939
2013
  throw new CliError(
1940
- 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning … | admin tuning-context (see --help)'
2014
+ 'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin crm … | admin evals … | admin tuning … | admin tuning-context | admin replays … (see --help)'
1941
2015
  );
1942
2016
  }
@@ -228,8 +228,109 @@ export const ARGS = {
228
228
  get_kernel_endpoint_run_steps: {
229
229
  run_id: { positional: 0, label: "run-id", required: true },
230
230
  },
231
+ // `kernel endpoint bind <id> --kernel <id|none> --shredder <id|none>`: "none" unbinds (null),
232
+ // which is not the same as leaving the flag out (unchanged).
233
+ update_kernel_endpoint: {
234
+ endpoint_id: { positional: 1, label: "endpoint-id", required: true },
235
+ kernel_id: { value: (flags) => noneIsNull(flags.kernel) },
236
+ shredder_endpoint_id: { value: (flags) => noneIsNull(flags.shredder) },
237
+ },
238
+
239
+ // ── Making a kernel your own, and choosing the org's ───────────────────────────────
240
+ configure_kernel: {
241
+ source_kernel_id: { positional: 0, label: "source-kernel-id", required: true },
242
+ name: "name",
243
+ },
244
+ create_kernel: {
245
+ file: { value: (flags) => readJsonFileFlag(flags, "file") },
246
+ },
247
+ get_org_writing_kernel: {},
248
+ set_org_writing_kernel: {
249
+ writing_kernel_id: {
250
+ value: (_flags, positionals) => {
251
+ const id = positionals[0];
252
+ if (id === undefined) throw new CliError("Missing required <kernel-id> (or none to clear it)");
253
+ return id === "none" ? null : id;
254
+ },
255
+ },
256
+ },
257
+
258
+ // ── The kernel file and its versions ──────────────────────────────────────────────
259
+ get_kernel_file: {
260
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
261
+ version: { flag: "version", type: "num" },
262
+ },
263
+ get_kernel_file_schema: {},
264
+ list_kernel_versions: {
265
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
266
+ },
267
+ // A candidate unless --live: the safe default is the one that changes no run.
268
+ save_kernel_file: {
269
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
270
+ file: { value: (flags) => readJsonFileFlag(flags, "file") },
271
+ status: { value: (flags) => (flags.live === true ? "live" : undefined) },
272
+ note: "note",
273
+ },
274
+ promote_kernel_version: {
275
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
276
+ version: { value: (_flags, positionals) => positiveInt(positionals[1], "version") },
277
+ },
278
+ get_kernel_offer: {
279
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
280
+ },
281
+ accept_kernel_offer: {
282
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
283
+ status: { value: (flags) => (flags.live === true ? "live" : undefined) },
284
+ },
285
+ suggest_kernel_voice_settings: {
286
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
287
+ notes: { value: (flags) => flagStr(flags.notes) ?? readTextFileFlag(flags, "notes-file") },
288
+ current: { value: (flags) => readJsonFileFlag(flags, "voice-config-file") },
289
+ },
290
+
291
+ // ── The Studio: try a kernel, keep the history ────────────────────────────────────
292
+ list_kernel_studio_runs: {
293
+ kernel_id: "kernel",
294
+ limit: { flag: "limit", type: "num" },
295
+ },
296
+ get_kernel_studio_run: {
297
+ run_id: { positional: 0, label: "run-id", required: true },
298
+ },
231
299
  };
232
300
 
301
+ /** `--kernel none` → null (unbind); a value → itself; absent → undefined (leave it). */
302
+ function noneIsNull(raw) {
303
+ const v = flagStr(raw);
304
+ if (v === undefined) return undefined;
305
+ return v === "none" ? null : v;
306
+ }
307
+
308
+ function positiveInt(raw, label) {
309
+ if (raw === undefined) throw new CliError(`Missing required <${label}>`);
310
+ const n = Number(raw);
311
+ if (!Number.isInteger(n) || n < 1) throw new CliError(`<${label}> must be a positive whole number (got "${raw}")`);
312
+ return n;
313
+ }
314
+
315
+ // `kernel try` is the Studio's run: the same per-run options as `kernel write` (one map, so a
316
+ // field run_kernel gains is a field this learns too), on a kernel rather than an endpoint.
317
+ {
318
+ const {
319
+ endpoint_id: _endpoint,
320
+ input: _input,
321
+ article_id: _article,
322
+ kernel_id: _kernel,
323
+ ...runOptions
324
+ } = ARGS.run_kernel;
325
+ ARGS.start_kernel_studio_run = {
326
+ kernel_id: { positional: 0, label: "kernel-id", required: true },
327
+ brief: { value: (flags) => readBodyInput(flags) },
328
+ // --name, not --title: --title is the template's title in the run options.
329
+ title: "name",
330
+ ...runOptions,
331
+ };
332
+ }
333
+
233
334
  export async function cmdKernel(ctx) {
234
335
  const sub = ctx.positionals[0];
235
336
  const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
@@ -257,11 +358,37 @@ export async function cmdKernel(ctx) {
257
358
  return kernelRunStatus(rest);
258
359
  case "runs":
259
360
  return kernelRuns(rest);
361
+ case "fork":
362
+ return kernelFork(rest);
363
+ case "create":
364
+ return kernelCreate(rest);
365
+ case "default":
366
+ return kernelDefault(rest);
367
+ case "use":
368
+ return kernelUse(rest);
369
+ case "file":
370
+ return kernelFile(rest);
371
+ case "schema":
372
+ return kernelSchema(rest);
373
+ case "versions":
374
+ return kernelVersions(rest);
375
+ case "save":
376
+ return kernelSave(rest);
377
+ case "promote":
378
+ return kernelPromote(rest);
379
+ case "offer":
380
+ return kernelOffer(rest);
381
+ case "suggest-voice":
382
+ return kernelSuggestVoice(rest);
383
+ case "try":
384
+ return kernelTry(rest);
385
+ case "history":
386
+ return kernelHistory(rest);
260
387
  case "balance":
261
388
  return kernelBalance(rest);
262
389
  default:
263
390
  throw new CliError(
264
- `Unknown kernel subcommand: ${sub ?? "(none)"}. Try: list, endpoints, endpoint, eval, update, archive, write, polish, run-status, runs, balance, status`
391
+ `Unknown kernel subcommand: ${sub ?? "(none)"}. Try: list, fork, create, default, use, file, schema, versions, save, promote, offer, update, suggest-voice, archive, eval, try, history, endpoints, endpoint, write, polish, run-status, runs, balance, status`
265
392
  );
266
393
  }
267
394
  }
@@ -435,7 +562,17 @@ async function kernelEndpoint(ctx) {
435
562
  printResult(io, flags, result);
436
563
  return 0;
437
564
  }
438
- throw new CliError(`Unknown kernel endpoint action: ${action ?? "(none)"}. Try: create, delete, config`);
565
+ if (action === "bind") {
566
+ const args = argsFromFlags(ARGS.update_kernel_endpoint, flags, positionals);
567
+ if (args.kernel_id === undefined && args.shredder_endpoint_id === undefined) {
568
+ throw new CliError("Provide --kernel <id|none> and/or --shredder <id|none>.");
569
+ }
570
+ const result = await client.callTool("update_kernel_endpoint", args);
571
+ ok(ctx, `Rebound kernel endpoint ${args.endpoint_id}.`);
572
+ printResult(io, flags, result);
573
+ return 0;
574
+ }
575
+ throw new CliError(`Unknown kernel endpoint action: ${action ?? "(none)"}. Try: create, delete, config, bind`);
439
576
  }
440
577
 
441
578
  /**
@@ -615,3 +752,232 @@ async function kernelBalance(ctx) {
615
752
  printResult(io, flags, result);
616
753
  return 0;
617
754
  }
755
+
756
+ /* ------------------------------------------------------------------ */
757
+ /* Making a kernel your own, and its file */
758
+ /* ------------------------------------------------------------------ */
759
+
760
+ /** `kernel fork <source-kernel-id> [--name <name>]` — copy a catalog kernel (or one of yours) into one your org can tune. */
761
+ async function kernelFork(ctx) {
762
+ const { client, positionals, flags, io } = ctx;
763
+ const result = await client.callTool("configure_kernel", argsFromFlags(ARGS.configure_kernel, flags, positionals));
764
+ const kernel = toolField(result, "kernel");
765
+ if (kernel?.id) ok(ctx, `Forked as "${kernel.name}" (${kernel.id}). Its file: ${ctx.bin} kernel file ${kernel.id}`);
766
+ printResult(io, flags, result);
767
+ return 0;
768
+ }
769
+
770
+ /** `kernel create --file kernel.json` — bring your own: a kernel made from a file (a name and a voice is enough). */
771
+ async function kernelCreate(ctx) {
772
+ const { client, positionals, flags, io } = ctx;
773
+ if (flagStr(flags.file) === undefined) {
774
+ throw new CliError('Provide the kernel file with --file <path> (as short as {"name": "…", "voice": "…"}).');
775
+ }
776
+ const result = await client.callTool("create_kernel", argsFromFlags(ARGS.create_kernel, flags, positionals));
777
+ const kernel = toolField(result, "kernel");
778
+ if (kernel?.id) ok(ctx, `Created "${kernel.name}" (${kernel.id}), version 1, live.`);
779
+ printResult(io, flags, result);
780
+ return 0;
781
+ }
782
+
783
+ /** `kernel default` — the org's selected writing kernel. */
784
+ async function kernelDefault(ctx) {
785
+ const { client, positionals, flags, io } = ctx;
786
+ const result = await client.callTool(
787
+ "get_org_writing_kernel",
788
+ argsFromFlags(ARGS.get_org_writing_kernel, flags, positionals)
789
+ );
790
+ printResult(io, flags, result);
791
+ return 0;
792
+ }
793
+
794
+ /** `kernel use <kernel-id|none>` — make a kernel the org's default (a shared one needs an org admin's key). */
795
+ async function kernelUse(ctx) {
796
+ const { client, positionals, flags, io } = ctx;
797
+ const args = argsFromFlags(ARGS.set_org_writing_kernel, flags, positionals);
798
+ const result = await client.callTool("set_org_writing_kernel", args);
799
+ ok(
800
+ ctx,
801
+ args.writing_kernel_id ? `The org now writes with ${args.writing_kernel_id}.` : "Cleared the org's kernel."
802
+ );
803
+ printResult(io, flags, result);
804
+ return 0;
805
+ }
806
+
807
+ /**
808
+ * `kernel file <kernel-id> [--version N]` — the kernel file itself, printed as the document so
809
+ * `> kernel.json` round-trips into `kernel save <kernel-id> --file kernel.json`.
810
+ */
811
+ async function kernelFile(ctx) {
812
+ const { client, positionals, flags, io } = ctx;
813
+ const result = await client.callTool("get_kernel_file", argsFromFlags(ARGS.get_kernel_file, flags, positionals));
814
+ printResult(io, flags, result, (r) => JSON.stringify(r?.file ?? r, null, 2));
815
+ return 0;
816
+ }
817
+
818
+ /** `kernel schema` — the kernel file's JSON Schema, as the kernels service validates it. */
819
+ async function kernelSchema(ctx) {
820
+ const { client, positionals, flags, io } = ctx;
821
+ const result = await client.callTool(
822
+ "get_kernel_file_schema",
823
+ argsFromFlags(ARGS.get_kernel_file_schema, flags, positionals)
824
+ );
825
+ printResult(io, flags, result, (r) => JSON.stringify(r?.schema ?? r, null, 2));
826
+ return 0;
827
+ }
828
+
829
+ /** `kernel versions <kernel-id>` — every version, the live one, the base it came from and any offer. */
830
+ async function kernelVersions(ctx) {
831
+ const { client, positionals, flags, io } = ctx;
832
+ const result = await client.callTool(
833
+ "list_kernel_versions",
834
+ argsFromFlags(ARGS.list_kernel_versions, flags, positionals)
835
+ );
836
+ printResult(io, flags, result, (r) => {
837
+ const lines = (r?.versions ?? []).map(
838
+ (v) =>
839
+ `v${v.version} ${v.status.padEnd(9)} ${v.writer.padEnd(18)} ${v.note ?? ""}${v.changes?.length ? ` [${v.changes.join(", ")}]` : ""}`
840
+ );
841
+ if (r?.base)
842
+ lines.push(
843
+ `\nCopied from ${r.base.name} (v${r.base.version}); differs in: ${r.base.changes.join(", ") || "nothing"}`
844
+ );
845
+ if (r?.offer)
846
+ lines.push(
847
+ `Offer: ${r.base?.name ?? "the base"} v${r.offer.base_version_from} → v${r.offer.base_version_to} changes ${r.offer.changes.join(", ")}`
848
+ );
849
+ return lines.join("\n");
850
+ });
851
+ return 0;
852
+ }
853
+
854
+ /**
855
+ * `kernel save <kernel-id> --file kernel.json [--live] [--note "…"]` — a new version. A candidate
856
+ * unless --live: try it with `kernel try <kernel-id> --kernel-version N`, then `kernel promote`.
857
+ */
858
+ async function kernelSave(ctx) {
859
+ const { client, positionals, flags, io } = ctx;
860
+ if (flagStr(flags.file) === undefined) throw new CliError("Provide the kernel file with --file <path>.");
861
+ const args = argsFromFlags(ARGS.save_kernel_file, flags, positionals);
862
+ const result = await client.callTool("save_kernel_file", args);
863
+ const version = toolField(result, "version");
864
+ ok(
865
+ ctx,
866
+ args.status === "live"
867
+ ? `Version ${version} is live.`
868
+ : `Saved version ${version} as a candidate. Try it: ${ctx.bin} kernel try ${args.kernel_id} --kernel-version ${version} --body "…"; ship it: ${ctx.bin} kernel promote ${args.kernel_id} ${version} --yes`
869
+ );
870
+ if (toolField(result, "voice_settings_detached"))
871
+ ok(ctx, "The voice is now edited as text; voice_config no longer applies.");
872
+ printResult(io, flags, result);
873
+ return 0;
874
+ }
875
+
876
+ /** `kernel promote <kernel-id> <version> --yes` — make a version live: ship a candidate, or roll back. */
877
+ async function kernelPromote(ctx) {
878
+ const { client, positionals, flags, io } = ctx;
879
+ const args = argsFromFlags(ARGS.promote_kernel_version, flags, positionals);
880
+ if (!flags.yes) {
881
+ io.error(
882
+ `Every run of this kernel will use version ${args.version} from its next call. Re-run with --yes to confirm: ${ctx.bin} kernel promote ${args.kernel_id} ${args.version} --yes`
883
+ );
884
+ return 1;
885
+ }
886
+ const result = await client.callTool("promote_kernel_version", args);
887
+ const previous = toolField(result, "previous_live");
888
+ ok(
889
+ ctx,
890
+ `Version ${args.version} is live.${previous ? ` To undo: ${ctx.bin} kernel promote ${args.kernel_id} ${previous} --yes` : ""}`
891
+ );
892
+ printResult(io, flags, result);
893
+ return 0;
894
+ }
895
+
896
+ /** `kernel offer <kernel-id> [--accept [--live]]` — the base's newer version, and taking it. */
897
+ async function kernelOffer(ctx) {
898
+ const { client, positionals, flags, io } = ctx;
899
+ if (flags.accept !== true) {
900
+ const result = await client.callTool(
901
+ "get_kernel_offer",
902
+ argsFromFlags(ARGS.get_kernel_offer, flags, positionals)
903
+ );
904
+ printResult(io, flags, result);
905
+ return 0;
906
+ }
907
+ const args = argsFromFlags(ARGS.accept_kernel_offer, flags, positionals);
908
+ const result = await client.callTool("accept_kernel_offer", args);
909
+ ok(ctx, `Accepted as version ${toolField(result, "version")} (${toolField(result, "status")}).`);
910
+ printResult(io, flags, result);
911
+ return 0;
912
+ }
913
+
914
+ /**
915
+ * `kernel suggest-voice <kernel-id> --notes "make it funnier" --voice-config-file current.json` —
916
+ * which voice settings a note asks for. Changes nothing; `kernel update --voice-config-file` saves.
917
+ */
918
+ async function kernelSuggestVoice(ctx) {
919
+ const { client, positionals, flags, io } = ctx;
920
+ const args = argsFromFlags(ARGS.suggest_kernel_voice_settings, flags, positionals);
921
+ if (args.notes === undefined) throw new CliError("Provide the note with --notes <text> or --notes-file <path>.");
922
+ if (args.current === undefined) {
923
+ throw new CliError(
924
+ "Provide the kernel's current voice settings with --voice-config-file <path> (register, humor, inquisitive)."
925
+ );
926
+ }
927
+ const result = await client.callTool("suggest_kernel_voice_settings", args);
928
+ printResult(io, flags, result);
929
+ return 0;
930
+ }
931
+
932
+ /**
933
+ * `kernel try <kernel-id> --body "…" [--kernel-version N] [--name "…"]` — the Studio's run: a
934
+ * throwaway article from any kernel the org can use, kept in the Studio's History. Follows the
935
+ * run like `write` unless --no-wait.
936
+ */
937
+ async function kernelTry(ctx) {
938
+ const { client, positionals, flags, io } = ctx;
939
+ const args = argsFromFlags(ARGS.start_kernel_studio_run, flags, positionals);
940
+ if (args.brief === undefined) {
941
+ throw new CliError("Provide the brief with --body <text>, --file <path>, or --file - (stdin).");
942
+ }
943
+ const result = await client.callTool("start_kernel_studio_run", args);
944
+ const runId = toolField(result, "run")?.external_id;
945
+ if (!runId || flags["no-wait"] === true) {
946
+ if (runId) ok(ctx, `Started ${runId}. Follow it with: ${ctx.bin} kernel history --kernel ${args.kernel_id}`);
947
+ printResult(io, flags, result);
948
+ return 0;
949
+ }
950
+ ok(ctx, `Started ${runId}. Waiting — this keeps running on the server if you stop.`);
951
+ let lastStatus;
952
+ const { settled, value: polled } = await followJob(
953
+ io,
954
+ async () => {
955
+ const r = await client.callTool(
956
+ "get_kernel_studio_run",
957
+ argsFromFlags(ARGS.get_kernel_studio_run, {}, [runId])
958
+ );
959
+ lastStatus = toolField(r, "run")?.status;
960
+ return { done: !!lastStatus && lastStatus !== "running", value: r };
961
+ },
962
+ { intervalMs: POLL_INTERVAL_MS, ceilingMs: POLL_CEILING_MS }
963
+ );
964
+ if (!settled) {
965
+ ok(ctx, `Still running after 45 minutes. It stays in the Studio's History: ${ctx.bin} kernel history`);
966
+ printResult(io, flags, polled);
967
+ return 0;
968
+ }
969
+ ok(ctx, `Run ${lastStatus}.`);
970
+ printResult(io, flags, polled, (v) => (typeof v?.article === "string" ? v.article : JSON.stringify(v, null, 2)));
971
+ return lastStatus === "done" ? 0 : 1;
972
+ }
973
+
974
+ /** `kernel history [--kernel <id>] [--limit N]` — the Studio's History. */
975
+ async function kernelHistory(ctx) {
976
+ const { client, positionals, flags, io } = ctx;
977
+ const result = await client.callTool(
978
+ "list_kernel_studio_runs",
979
+ argsFromFlags(ARGS.list_kernel_studio_runs, flags, positionals)
980
+ );
981
+ printResult(io, flags, result);
982
+ return 0;
983
+ }
@@ -59,6 +59,8 @@ const SEQUENCE_RAIL_ARGS = {
59
59
  postal_address_footer: { flag: "postal-address-footer", type: "bool", negate: "no-postal-address-footer" },
60
60
  unsubscribe_links: { flag: "unsubscribe-links", type: "bool", negate: "no-unsubscribe-links" },
61
61
  detect_reply_opt_outs: { flag: "detect-reply-opt-outs", type: "bool", negate: "no-detect-reply-opt-outs" },
62
+ // Staff-only to turn on (the server refuses it for anyone else): walks as live, sends nothing.
63
+ test_mode: { flag: "test-mode", type: "bool", negate: "no-test-mode" },
62
64
  };
63
65
 
64
66
  // A send-window edge from --<flag> HH:MM (or a raw minute of day) → the
@@ -158,6 +160,17 @@ export const ARGS = {
158
160
  item_id: { positional: 0, label: "item-id", required: true },
159
161
  action: { flag: "action", required: true, enum: ["approve", "skip", "delete"] },
160
162
  },
163
+ list_dispatches: {},
164
+ create_dispatch: {
165
+ name: { flag: "name", required: true },
166
+ },
167
+ update_dispatch: {
168
+ dispatch_id: { positional: 0, label: "dispatch-id", required: true },
169
+ name: { flag: "name", required: true },
170
+ },
171
+ delete_dispatch: {
172
+ dispatch_id: { positional: 0, label: "dispatch-id", required: true },
173
+ },
161
174
  list_dispatch_links: {},
162
175
  create_dispatch_link: {
163
176
  days: { flag: "days", type: "num", required: true },
@@ -210,6 +223,26 @@ export const ARGS = {
210
223
  status: { flag: "status", enum: PROSPECT_STATUSES },
211
224
  q: "q",
212
225
  },
226
+ delete_prospects: {
227
+ prospect_ids: {
228
+ value: (flags, positionals) => {
229
+ const ids = [...positionals, ...flagList(flags.id)];
230
+ if (!ids.length) throw new CliError("Missing <prospect-id>... (or --id <prospect-id>, repeatable)");
231
+ return ids;
232
+ },
233
+ },
234
+ },
235
+ create_prospect_column: {
236
+ label: { positional: 0, label: "label", required: true },
237
+ },
238
+ delete_prospect_column: {
239
+ column_id: { positional: 0, label: "column-id", required: true },
240
+ },
241
+ generate_prospect_openers: {
242
+ list_id: { positional: 0, label: "list-id", required: true },
243
+ group_id: "group",
244
+ new_group_name: "new-group",
245
+ },
213
246
  update_prospect: {
214
247
  prospect_id: { positional: 0, label: "prospect-id", required: true },
215
248
  status: { flag: "status", enum: PROSPECT_STATUSES },
@@ -292,6 +325,7 @@ const VALUE_FLAGS = {
292
325
  update_sequence: ["flow", "flow-file", "mailer", "send-window-start", "send-window-end"],
293
326
  enroll_in_sequence: ["recipients", "recipients-file", "email"],
294
327
  update_review_item: ["body", "file"],
328
+ delete_prospects: ["id"],
295
329
  import_prospects_csv: ["csv", "csv-file"],
296
330
  update_prospect: ["custom"],
297
331
  set_mailer_forwarding: ["to", "clear"],
@@ -306,6 +340,37 @@ async function run(ctx, tool, message) {
306
340
  }
307
341
 
308
342
  const SEQUENCES_USAGE = `Usage: sequences <list|get|create|update|pause|resume|enroll|stop|review|links>`;
343
+ const DISPATCHES_USAGE = `Usage: dispatches <list|create|rename|delete>`;
344
+
345
+ // Dispatches: the named review desks a sequence's review stage queues to.
346
+ export async function cmdDispatches(ctx) {
347
+ const sub = ctx.positionals[0];
348
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
349
+ switch (sub) {
350
+ case "list":
351
+ case "ls":
352
+ case undefined:
353
+ return run(rest, "list_dispatches");
354
+ case "create":
355
+ case "new":
356
+ return run(rest, "create_dispatch", "Dispatch created.");
357
+ case "rename":
358
+ case "update":
359
+ return run(rest, "update_dispatch", "Dispatch renamed.");
360
+ case "delete": {
361
+ if (!ctx.flags.yes) {
362
+ const id = flagStr(rest.positionals[0]) ?? "<dispatch-id>";
363
+ ctx.io.error(
364
+ `Its queued emails move to the default desk. Re-run with --yes to confirm: ${ctx.bin} dispatches delete ${id} --yes`
365
+ );
366
+ return 1;
367
+ }
368
+ return run(rest, "delete_dispatch", "Dispatch deleted; its queued emails moved to the default desk.");
369
+ }
370
+ default:
371
+ throw new CliError(`Unknown dispatches subcommand: ${sub}. ${DISPATCHES_USAGE}`);
372
+ }
373
+ }
309
374
 
310
375
  export async function cmdSequences(ctx) {
311
376
  const sub = ctx.positionals[0];
@@ -395,7 +460,7 @@ async function sequencesLinks(ctx, sub) {
395
460
  }
396
461
  }
397
462
 
398
- const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|delete-list|import|source|enrich|update>`;
463
+ const PROSPECTS_USAGE = `Usage: prospects <lists|list|ls|get|create-list|delete-list|import|source|enrich|openers|update|delete|columns>`;
399
464
 
400
465
  export async function cmdProspects(ctx) {
401
466
  const sub = ctx.positionals[0];
@@ -424,13 +489,46 @@ export async function cmdProspects(ctx) {
424
489
  );
425
490
  case "enrich":
426
491
  return run(rest, "enrich_prospects", "Enrichment queued.");
492
+ case "openers":
493
+ return run(rest, "generate_prospect_openers", "Printing openers; they fill in as each one lands.");
427
494
  case "update":
428
495
  return run(rest, "update_prospect", "Updated prospect.");
496
+ case "delete":
497
+ case "rm": {
498
+ if (!ctx.flags.yes) {
499
+ ctx.io.error(`Deleting prospects cannot be undone. Re-run with --yes to confirm.`);
500
+ return 1;
501
+ }
502
+ return run(rest, "delete_prospects", "Deleted.");
503
+ }
504
+ case "columns":
505
+ return prospectsColumns(rest, rest.positionals[0]);
429
506
  default:
430
507
  throw new CliError(`Unknown prospects subcommand: ${sub ?? "(none)"}. ${PROSPECTS_USAGE}`);
431
508
  }
432
509
  }
433
510
 
511
+ async function prospectsColumns(ctx, sub) {
512
+ const inner = { ...ctx, positionals: ctx.positionals.slice(1) };
513
+ switch (sub) {
514
+ case "add":
515
+ return run(inner, "create_prospect_column", "Column added.");
516
+ case "delete":
517
+ case "rm": {
518
+ if (!ctx.flags.yes) {
519
+ const id = flagStr(inner.positionals[0]) ?? "<column-id>";
520
+ ctx.io.error(`Re-run with --yes to confirm: ${ctx.bin} prospects columns delete ${id} --yes`);
521
+ return 1;
522
+ }
523
+ return run(inner, "delete_prospect_column", "Column removed.");
524
+ }
525
+ default:
526
+ throw new CliError(
527
+ `Unknown columns subcommand: ${sub ?? "(none)"}. Try: add <label>, delete <column-id> --yes`
528
+ );
529
+ }
530
+ }
531
+
434
532
  const MAILERS_USAGE = `Usage: mailers <list|domain|messages|message|forward|warmup|limit|retire|suppressions>`;
435
533
 
436
534
  export async function cmdMailers(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.64.0",
3
+ "version": "0.66.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",