@letterstory/cli 0.36.1 → 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/README.md +23 -0
- package/lib/cli.mjs +101 -1
- package/lib/commands/admin.mjs +196 -3
- package/lib/commands/lede.mjs +147 -2
- package/lib/commands/outbound.mjs +324 -0
- package/lib/commands.mjs +1 -0
- package/lib/documents.mjs +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -324,6 +324,29 @@ letterstory admin ask "which clients published nothing in the last 30 days?" --s
|
|
|
324
324
|
This is the headless half of `/superadmin/ask` — same function, same schema — so a
|
|
325
325
|
long-running agent can watch the fleet on a schedule without a browser.
|
|
326
326
|
|
|
327
|
+
### Staff: kernel experiments
|
|
328
|
+
|
|
329
|
+
```bash
|
|
330
|
+
# Needs a STAFF key with the tuning:experiment capability. A key always acts as an
|
|
331
|
+
# agent: it can propose, plan, note, spend and conclude, never approve a plan.
|
|
332
|
+
export LETTERSTORY_STAFF_API_KEY=<your staff key with tuning:experiment>
|
|
333
|
+
|
|
334
|
+
letterstory admin tuning-context --q "colons" # what the agent knows (JSON)
|
|
335
|
+
letterstory admin experiments list --q colons # what we've tried
|
|
336
|
+
letterstory admin experiments new "We use too many colons" --as "claude-code"
|
|
337
|
+
letterstory admin experiments update <id> --plan-file plan.json --budget 120
|
|
338
|
+
# ...a person approves the plan in Kernel Tuning -> Experiments...
|
|
339
|
+
letterstory admin experiments spend <id> 54
|
|
340
|
+
letterstory admin tuning-import lab/results/colons/publish/<brief>.json --experiment <id>
|
|
341
|
+
letterstory admin experiments conclude <id> --decision drop \
|
|
342
|
+
--conclusion "Colons fell 60%; P(machine) did not move; quality held." \
|
|
343
|
+
--evidence lab:"kernels lab/results/colons/report.md"
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The ledger behind Kernel Tuning's Experiments tab
|
|
347
|
+
(docs/architecture/kernel-experiment-agent.md): every question asked of the kernels,
|
|
348
|
+
how it was tested, and what we learned and why.
|
|
349
|
+
|
|
327
350
|
### Propose, then onboard a phantom
|
|
328
351
|
|
|
329
352
|
The Fleet wizard's Plan and Name screens as two commands: pick the beat, then the name,
|
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.
|
|
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.
|
|
@@ -506,6 +519,24 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
506
519
|
read-only SQL across every org; --sql prints
|
|
507
520
|
the queries behind the answer. Needs the
|
|
508
521
|
fleet:query capability on the staff key.
|
|
522
|
+
admin experiments list [--q colons] [--status proposed] | show <id>
|
|
523
|
+
| new "<question>" [--hypothesis …] [--plan-file p.json] [--tags a,b] [--as "<agent>"]
|
|
524
|
+
| update <id> [--plan-file …] [--result-file …] [--evidence kind:ref,…] [--budget N]
|
|
525
|
+
| note|started <id> "<text>" | spend <id> <runs>
|
|
526
|
+
| conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "…" [--evidence …]
|
|
527
|
+
| abandon <id> --reason "…"
|
|
528
|
+
The kernel experiment ledger: what we tried,
|
|
529
|
+
how, what it found and why. Approving a plan
|
|
530
|
+
stays with a signed-in person. Needs the
|
|
531
|
+
tuning:experiment capability.
|
|
532
|
+
admin tuning-import <brief-results.json> [--experiment <id>]
|
|
533
|
+
One brief's kernels lab results as a console
|
|
534
|
+
session (a trial per arm), linked to its
|
|
535
|
+
ledger entry. Written by \`npm run lab --
|
|
536
|
+
publish\` in the kernels repo.
|
|
537
|
+
admin tuning-context [--q "<question>"] The experiment agent's context pack (JSON):
|
|
538
|
+
the pipeline as deployed, levers, measures,
|
|
539
|
+
goals, related history.
|
|
509
540
|
admin create-org --domain <bare-domain> [--name "Acme"] [--test] [--no-key]
|
|
510
541
|
[--key-name N] [--capabilities a,b] [--onboard] [--watch] [+ the onboard domain flags]
|
|
511
542
|
The key exchange: create the client org from
|
|
@@ -624,6 +655,8 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
|
|
|
624
655
|
[--years <n>] [--linkedin <url>] [--notes <text>]
|
|
625
656
|
[--candidates <1..10>] [--tone warm|direct|playful|formal] [--no-enrich]
|
|
626
657
|
[--deadline-ms <2000..120000>]
|
|
658
|
+
[--default-lede <line>] [--disable-lede <id>]... [--diverse [--recent-lede <id>]...]
|
|
659
|
+
[--high-temp|--no-high-temp]
|
|
627
660
|
Synchronous: prints the candidate lines with the
|
|
628
661
|
top choice flagged. Needs at least one of
|
|
629
662
|
--first-name, --name, --email, --company or
|
|
@@ -632,10 +665,39 @@ Ledes (opening lines for cold email, chosen per prospect from a hand-approved li
|
|
|
632
665
|
lookup when latency matters more than fit.
|
|
633
666
|
--deadline-ms leaves out evidence that cannot
|
|
634
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.
|
|
635
676
|
lede runs [--endpoint <id>] [--limit <n>] Recent lede calls (telemetry), newest first
|
|
636
677
|
lede history [<domain>] [--days <n>] [--limit <n>] Openers already sent to an account
|
|
637
678
|
lede evals [--sweep] Lede quality evals still above zero
|
|
638
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
|
|
639
701
|
|
|
640
702
|
Landings (a link + context; auto-mentioned in articles by collections you enable):
|
|
641
703
|
landings list Your landing pages + mention counts
|
|
@@ -645,6 +707,41 @@ Landings (a link + context; auto-mentioned in articles by collections you enable
|
|
|
645
707
|
landings update <landing-id> [--link <url>] [--context <text>|--file <path>]
|
|
646
708
|
landings delete <landing-id> --yes
|
|
647
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
|
+
|
|
648
745
|
Insights:
|
|
649
746
|
insights site [--period 14d|30d|90d] [--collection <uuid>]
|
|
650
747
|
insights post <article-id> [--period 14d|30d|90d]
|
|
@@ -722,6 +819,9 @@ const CLIENT_COMMANDS = {
|
|
|
722
819
|
lede: cmdLede,
|
|
723
820
|
ledes: cmdLede,
|
|
724
821
|
landings: cmdLandings,
|
|
822
|
+
sequences: cmdSequences,
|
|
823
|
+
prospects: cmdProspects,
|
|
824
|
+
mail: cmdMail,
|
|
725
825
|
feedback: cmdFeedback,
|
|
726
826
|
// Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
|
|
727
827
|
admin: cmdAdmin,
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
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 experiments list|show|new|update|note|started|spend|conclude|abandon … (tuning:experiment)
|
|
8
|
+
// admin tuning-context [--q "<question>"] (tuning:experiment)
|
|
9
|
+
// admin tuning-import <brief-results.json> [--experiment <id>] (tuning:experiment)
|
|
7
10
|
// admin create-org --domain acme.com [--name "Acme"] [--test] [--no-key] [--key-name N]
|
|
8
11
|
// [--capabilities a,b] [--onboard] [--about "…"] [--sitemap <url>]
|
|
9
12
|
// [--cms <provider>] [--posts N] [--seers suggest|skip] [--no-visuals]
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
// The staff key comes from --staff-key or LETTERSTORY_STAFF_API_KEY; the base URL from the
|
|
27
30
|
// usual --url / LETTERSTORY_API_URL / config resolution.
|
|
28
31
|
|
|
32
|
+
import { readFileSync } from "node:fs";
|
|
29
33
|
import { CliError, LetterstoryClient } from "../client.mjs";
|
|
30
34
|
import { flagStr, flagNum, flagBool, flagList, printResult, compact, ok } from "./shared.mjs";
|
|
31
35
|
import { watchDomainJob } from "./onboard.mjs";
|
|
@@ -46,12 +50,17 @@ function staffKeyFrom(flags) {
|
|
|
46
50
|
|
|
47
51
|
/** POST a superadmin route with the staff key; surfaces the server's error text on non-2xx. */
|
|
48
52
|
async function staffPost(ctx, path, body) {
|
|
53
|
+
return staffRequest(ctx, "POST", path, body);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Any method against a superadmin route with the staff key. */
|
|
57
|
+
async function staffRequest(ctx, method, path, body) {
|
|
49
58
|
const { client, flags } = ctx;
|
|
50
59
|
const url = `${client.url}${path}`;
|
|
51
60
|
const res = await client.fetch(url, {
|
|
52
|
-
method
|
|
61
|
+
method,
|
|
53
62
|
headers: { "content-type": "application/json", "x-staff-api-key": staffKeyFrom(flags) },
|
|
54
|
-
body: JSON.stringify(body),
|
|
63
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
55
64
|
});
|
|
56
65
|
let payload = null;
|
|
57
66
|
try {
|
|
@@ -220,11 +229,195 @@ async function cmdAdminAsk(ctx) {
|
|
|
220
229
|
}
|
|
221
230
|
}
|
|
222
231
|
|
|
232
|
+
/* ------------------------------------------------------------ kernel experiments */
|
|
233
|
+
|
|
234
|
+
const EXPERIMENTS = "/api/superadmin/tuning/experiments";
|
|
235
|
+
|
|
236
|
+
/** `--as "<agent name>"` records the actor as that agent; a staff key always acts as an agent. */
|
|
237
|
+
function actorFrom(flags) {
|
|
238
|
+
const as = flagStr(flags.as);
|
|
239
|
+
return as ? { kind: "agent", label: as } : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A JSON object from `--<name>-file <path>` (or `-` for stdin is not supported: pass a file). */
|
|
243
|
+
function jsonFile(flags, name) {
|
|
244
|
+
const path = flagStr(flags[`${name}-file`]);
|
|
245
|
+
if (!path) return undefined;
|
|
246
|
+
try {
|
|
247
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
248
|
+
} catch (err) {
|
|
249
|
+
throw new CliError(`--${name}-file ${path}: ${err.message}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** `--evidence kind:ref[,kind:ref…]`, e.g. `lab:kernels lab/results/colons/report.md,session:<uuid>`. */
|
|
254
|
+
function evidenceFrom(flags) {
|
|
255
|
+
const items = flagList(flags.evidence);
|
|
256
|
+
if (!items?.length) return undefined;
|
|
257
|
+
return items.map((item) => {
|
|
258
|
+
const at = item.indexOf(":");
|
|
259
|
+
if (at < 1) throw new CliError(`--evidence items are kind:ref, got "${item}"`);
|
|
260
|
+
return { kind: item.slice(0, at), ref: item.slice(at + 1) };
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function experimentLine(e) {
|
|
265
|
+
const decision = e.decision ? ` → ${e.decision}` : "";
|
|
266
|
+
return `${e.id} [${e.status}${decision}] ${e.question}${e.gist ? `\n ${e.gist}` : ""}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `admin experiments …` — the experiment ledger (docs/architecture/kernel-experiment-agent.md),
|
|
271
|
+
* the headless half of the tuning console's Experiments tab. Needs `tuning:experiment` on the key.
|
|
272
|
+
*/
|
|
273
|
+
async function cmdAdminExperiments(ctx) {
|
|
274
|
+
const [sub, id, ...rest] = ctx.positionals;
|
|
275
|
+
const { flags, io } = ctx;
|
|
276
|
+
const json = flagBool(flags.json);
|
|
277
|
+
const out = (result, line) => (json ? printResult(io, flags, result) : io.log(line(result)));
|
|
278
|
+
const one = (r) => {
|
|
279
|
+
const e = r.experiment;
|
|
280
|
+
const lines = [experimentLine(e)];
|
|
281
|
+
if (e.restated) lines.push(` hypothesis: ${e.restated}`);
|
|
282
|
+
if (e.plan) lines.push(` plan: ${JSON.stringify(e.plan)}`);
|
|
283
|
+
lines.push(` runs: ${e.runsSpent} of ${e.budgetRuns}`);
|
|
284
|
+
for (const ev of e.events ?? [])
|
|
285
|
+
lines.push(` ${ev.at} ${ev.kind} ${ev.actorLabel ?? ev.actorKind}${ev.text ? `: ${ev.text}` : ""}`);
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
};
|
|
288
|
+
switch (sub) {
|
|
289
|
+
case "list": {
|
|
290
|
+
const params = new URLSearchParams(
|
|
291
|
+
compact({ q: flagStr(flags.q), status: flagStr(flags.status), limit: flagStr(flags.limit) })
|
|
292
|
+
);
|
|
293
|
+
const result = await staffRequest(ctx, "GET", `${EXPERIMENTS}?${params}`);
|
|
294
|
+
return out(result, (r) =>
|
|
295
|
+
r.experiments.length ? r.experiments.map(experimentLine).join("\n") : "(no experiments)"
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
case "show":
|
|
299
|
+
if (!id) throw new CliError("Usage: admin experiments show <id>");
|
|
300
|
+
return out(await staffRequest(ctx, "GET", `${EXPERIMENTS}/${id}`), one);
|
|
301
|
+
case "new": {
|
|
302
|
+
const question = [id, ...rest].filter(Boolean).join(" ").trim();
|
|
303
|
+
if (question.length < 3)
|
|
304
|
+
throw new CliError('Usage: admin experiments new "<question>" [--plan-file plan.json] [--tags a,b]');
|
|
305
|
+
const body = compact({
|
|
306
|
+
question,
|
|
307
|
+
restated: flagStr(flags.hypothesis),
|
|
308
|
+
plan: jsonFile(flags, "plan"),
|
|
309
|
+
method: flagStr(flags.method),
|
|
310
|
+
tags: flagList(flags.tags),
|
|
311
|
+
builds_on: flagList(flags["builds-on"]),
|
|
312
|
+
evidence: evidenceFrom(flags),
|
|
313
|
+
actor: actorFrom(flags),
|
|
314
|
+
});
|
|
315
|
+
return out(await staffRequest(ctx, "POST", EXPERIMENTS, body), one);
|
|
316
|
+
}
|
|
317
|
+
case "update": {
|
|
318
|
+
if (!id)
|
|
319
|
+
throw new CliError(
|
|
320
|
+
"Usage: admin experiments update <id> [--hypothesis …] [--plan-file …] [--result-file …] [--evidence kind:ref,…]"
|
|
321
|
+
);
|
|
322
|
+
const body = compact({
|
|
323
|
+
restated: flagStr(flags.hypothesis),
|
|
324
|
+
plan: jsonFile(flags, "plan"),
|
|
325
|
+
measures: jsonFile(flags, "measures"),
|
|
326
|
+
result: jsonFile(flags, "result"),
|
|
327
|
+
method: flagStr(flags.method),
|
|
328
|
+
tags: flagList(flags.tags),
|
|
329
|
+
budget_runs: flagNum(flags.budget),
|
|
330
|
+
engine_build: flagStr(flags.build),
|
|
331
|
+
add_evidence: evidenceFrom(flags),
|
|
332
|
+
actor: actorFrom(flags),
|
|
333
|
+
});
|
|
334
|
+
return out(await staffRequest(ctx, "PATCH", `${EXPERIMENTS}/${id}`, body), one);
|
|
335
|
+
}
|
|
336
|
+
case "note":
|
|
337
|
+
case "started": {
|
|
338
|
+
const text = rest.join(" ").trim();
|
|
339
|
+
if (!id || (sub === "note" && !text)) throw new CliError(`Usage: admin experiments ${sub} <id> "<text>"`);
|
|
340
|
+
const body = compact({ kind: sub, text: text || undefined, actor: actorFrom(flags) });
|
|
341
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
|
|
342
|
+
}
|
|
343
|
+
case "spend": {
|
|
344
|
+
const runs = Number(rest[0]);
|
|
345
|
+
if (!id || !Number.isInteger(runs) || runs < 1)
|
|
346
|
+
throw new CliError("Usage: admin experiments spend <id> <runs> [--text …]");
|
|
347
|
+
const body = compact({ kind: "spend", runs, text: flagStr(flags.text), actor: actorFrom(flags) });
|
|
348
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/events`, body), one);
|
|
349
|
+
}
|
|
350
|
+
case "conclude": {
|
|
351
|
+
const decision = flagStr(flags.decision);
|
|
352
|
+
const conclusion = flagStr(flags.conclusion);
|
|
353
|
+
if (!id || !decision || !conclusion)
|
|
354
|
+
throw new CliError(
|
|
355
|
+
'Usage: admin experiments conclude <id> --decision ship|drop|follow-up|inconclusive --conclusion "<what we learned and why>" [--evidence kind:ref,…] [--result-file r.json]'
|
|
356
|
+
);
|
|
357
|
+
const body = compact({
|
|
358
|
+
action: "conclude",
|
|
359
|
+
decision,
|
|
360
|
+
conclusion,
|
|
361
|
+
result: jsonFile(flags, "result"),
|
|
362
|
+
add_evidence: evidenceFrom(flags),
|
|
363
|
+
actor: actorFrom(flags),
|
|
364
|
+
});
|
|
365
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
|
|
366
|
+
}
|
|
367
|
+
case "abandon": {
|
|
368
|
+
const reason = flagStr(flags.reason);
|
|
369
|
+
if (!id || !reason) throw new CliError('Usage: admin experiments abandon <id> --reason "<why>"');
|
|
370
|
+
const body = compact({ action: "abandon", reason, actor: actorFrom(flags) });
|
|
371
|
+
return out(await staffRequest(ctx, "POST", `${EXPERIMENTS}/${id}/decide`, body), one);
|
|
372
|
+
}
|
|
373
|
+
default:
|
|
374
|
+
throw new CliError(
|
|
375
|
+
"Usage: admin experiments list|show|new|update|note|started|spend|conclude|abandon (see --help)"
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* `admin tuning-import <file.json>` — one brief's kernels lab results into a console session
|
|
382
|
+
* (the lab's `publish` writes these files; the body is POST /api/superadmin/tuning/lab-import's).
|
|
383
|
+
*/
|
|
384
|
+
async function cmdAdminTuningImport(ctx) {
|
|
385
|
+
const path = ctx.positionals[0];
|
|
386
|
+
if (!path) throw new CliError("Usage: admin tuning-import <brief-results.json> [--experiment <id>]");
|
|
387
|
+
let body;
|
|
388
|
+
try {
|
|
389
|
+
body = JSON.parse(readFileSync(path, "utf8"));
|
|
390
|
+
} catch (err) {
|
|
391
|
+
throw new CliError(`${path}: ${err.message}`);
|
|
392
|
+
}
|
|
393
|
+
const experiment = flagStr(ctx.flags.experiment);
|
|
394
|
+
if (experiment) body.experiment_id = experiment;
|
|
395
|
+
const result = await staffRequest(ctx, "POST", "/api/superadmin/tuning/lab-import", body);
|
|
396
|
+
if (flagBool(ctx.flags.json)) return printResult(ctx.io, ctx.flags, result);
|
|
397
|
+
ctx.io.log(
|
|
398
|
+
`${result.trials} trials, ${result.runs} runs → ${ctx.client.url}/superadmin/tuning/${result.sessionId}`
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** `admin tuning-context [--q "<question>"]` — the experiment agent's context pack, as JSON. */
|
|
403
|
+
async function cmdAdminTuningContext(ctx) {
|
|
404
|
+
const q = flagStr(ctx.flags.q) || ctx.positionals.join(" ").trim() || undefined;
|
|
405
|
+
const params = q ? `?${new URLSearchParams({ q })}` : "";
|
|
406
|
+
printResult(
|
|
407
|
+
ctx.io,
|
|
408
|
+
{ ...ctx.flags, json: true },
|
|
409
|
+
await staffRequest(ctx, "GET", `/api/superadmin/tuning/context${params}`)
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
223
413
|
export async function cmdAdmin(ctx) {
|
|
224
414
|
const sub = ctx.positionals[0];
|
|
225
415
|
if (sub === "create-org") return cmdAdminCreateOrg({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
226
416
|
if (sub === "ask") return cmdAdminAsk({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
417
|
+
if (sub === "experiments") return cmdAdminExperiments({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
418
|
+
if (sub === "tuning-context") return cmdAdminTuningContext({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
419
|
+
if (sub === "tuning-import") return cmdAdminTuningImport({ ...ctx, positionals: ctx.positionals.slice(1) });
|
|
227
420
|
throw new CliError(
|
|
228
|
-
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" (see --help)'
|
|
421
|
+
'Usage: admin create-org --domain <bare-domain> [--onboard] … | admin ask "<question>" | admin experiments … | admin tuning-context (see --help)'
|
|
229
422
|
);
|
|
230
423
|
}
|
package/lib/commands/lede.mjs
CHANGED
|
@@ -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) =>
|
|
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. */
|