@wrangled/cli 0.2.0 → 0.3.1

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/dist/main.js CHANGED
@@ -9,7 +9,7 @@ import { brand, envPrefix } from "./brand.js";
9
9
  import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
10
10
  import { guide, skill } from "./guide.js";
11
11
  import { PULL_STATE_FILE, pullContent } from "./pull.js";
12
- import { AGENTS_MD_MARKER, META_ADS_SKILL_NAME, metaAdsAgentsMd, metaAdsPlaybook, metaAdsSkill, P3_REFRESH_SKILL_NAME, p3RefreshPlaybook, p3RefreshSkill, } from "./playbooks.js";
12
+ import { AGENTS_MD_MARKER, metaAdsAgentsMd, PLAYBOOK_CHOICES, playbooks } from "./playbooks.js";
13
13
  import { AGENT_ENV, HARNESS_COMMAND_ENV, RUNNER_TOKEN_ENV, runLoop, runOnce } from "./runner/run.js";
14
14
  import { SKILL_NAME_PATTERN } from "./skill-zip.js";
15
15
  import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
@@ -102,20 +102,18 @@ const commands = {
102
102
  },
103
103
  },
104
104
  "warehouse query": {
105
- usage: 'warehouse query "<sql>" | --file <path> | < query.sql',
105
+ usage: 'warehouse query "<sql>" | --file <path> | < query.sql [--out <file.csv | file.json>]',
106
106
  async run(args, options) {
107
107
  const sql = await readSql(args, options.file);
108
108
  const text = await apiText(await resolveConnection(options.apiUrl), "/api/v1/warehouse/query", { sql });
109
+ if (options.out !== undefined)
110
+ return saveQueryResult(text, options.out, options.format);
109
111
  if (options.format === "json")
110
112
  return console.log(text);
111
- const result = JSON.parse(text);
112
- const exact = JSON.parse(text, keepNumberDigits);
113
- const rows = result.rows.map((row, i) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, typeof v === "number" ? exact.rows[i][k] : v])));
114
- console.log(renderTable(result.columns.map((c) => c.name), rows));
115
- console.log(`(${result.rows.length} ${result.rows.length === 1 ? "row" : "rows"})`);
116
- if (result.truncated) {
117
- console.error(`Results were cut off (${result.truncated}). Aggregate in SQL or add LIMIT.`);
118
- }
113
+ const result = queryResultWithExactNumbers(text);
114
+ console.log(renderTable(result.columns.map((c) => c.name), result.rows));
115
+ console.log(`(${rowCount(result.rows.length)})`);
116
+ warnIfTruncated(result);
119
117
  },
120
118
  },
121
119
  funnel: {
@@ -128,6 +126,7 @@ const commands = {
128
126
  if (options.format === "json")
129
127
  return printJson({ funnel });
130
128
  printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], FUNNEL_NUMBERS);
129
+ printLeftOut(funnel.left_out, "sales, revenue_net and roas");
131
130
  },
132
131
  },
133
132
  money: {
@@ -142,8 +141,12 @@ const commands = {
142
141
  printReport(`Money by ${money.grain}`, money, ["period", "currency"], MONEY_NUMBERS);
143
142
  if (money.by_source.length > 0)
144
143
  console.log(`\nMoney in by payment source:\n${renderTable(["source", "currency", "gross", "refunds", "net", "orders"], money.by_source)}`);
144
+ if (money.store_orders_in_stripe?.length) {
145
+ console.log(`\nStore orders paid through Stripe, counted under stripe:\n${renderTable(["source", "currency", "orders", "total"], money.store_orders_in_stripe)}`);
146
+ }
145
147
  if (money.refund_days.length > 0)
146
148
  console.log(`\nBiggest refund days:\n${renderTable(["date", "currency", "refunds"], money.refund_days)}`);
149
+ printLeftOut(money.left_out, "roas (net still counts it)");
147
150
  },
148
151
  },
149
152
  campaigns: {
@@ -157,6 +160,7 @@ const commands = {
157
160
  return printJson({ campaignResults });
158
161
  const title = campaignResults.channel === null ? "Campaigns, best first" : `${campaignResults.channel} campaigns, best first`;
159
162
  printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"], CAMPAIGN_NUMBERS);
163
+ printLeftOut(campaignResults.left_out, "the totals (the rows keep it)");
160
164
  },
161
165
  },
162
166
  "tools list": {
@@ -258,8 +262,9 @@ const commands = {
258
262
  const missing = source.missingAccess ?? [];
259
263
  if (missing.length > 0)
260
264
  console.log(`\nMissing access:\n${missing.map((m) => `- ${m.message}`).join("\n")}`);
261
- if (source.tables.length > 0)
262
- console.log(`\n${renderTable(["name", "rows", "earliest", "latest"], source.tables)}`);
265
+ const tables = source.tables.map((t) => ({ ...t, rows: t.rowsEstimated ? `about ${t.rows}` : t.rows }));
266
+ if (tables.length > 0)
267
+ console.log(`\n${renderTable(["name", "rows", "earliest", "latest"], tables)}`);
263
268
  if (runs.length === 0)
264
269
  return console.log("\nNo sync runs yet.");
265
270
  const runRows = runs.map((r) => ({ ...r, error: r.error && excerpt(r.error) }));
@@ -479,11 +484,7 @@ const commands = {
479
484
  const target = await downloadTarget(args[0], connection);
480
485
  const { bytes, contentType } = await apiDownload(connection, `${ASSET_ROUTE}${target.assetPath.split("/").map(encodeURIComponent).join("/")}`);
481
486
  const path = await savePath(options.out, extname(target.fileName) ? target.fileName : `${target.fileName}.${extensionFor(contentType)}`);
482
- await writeFileAtomic(path, bytes, 0o644).catch((error) => {
483
- if (error.code === "ENOENT")
484
- throw new CliError(`No folder at ${dirname(path)} to save into.`, exitCodes.usage);
485
- throw error;
486
- });
487
+ await saveFile(path, bytes);
487
488
  if (options.open)
488
489
  openFile(resolve(path));
489
490
  if (options.format === "json")
@@ -551,6 +552,34 @@ const commands = {
551
552
  process.stdout.write(guide.text);
552
553
  },
553
554
  },
555
+ brief: {
556
+ usage: "brief [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--channel <channel>] [--campaign <id>] [--top <n>] (start here before writing new ads or a campaign plan)",
557
+ async run(args, options) {
558
+ if (args.length > 0)
559
+ throw new CliError("Usage: brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id>] [--top <n>]", exitCodes.usage);
560
+ const query = reportQuery(options, { channel: options.channel, campaignId: options.campaign, top: options.top });
561
+ const brief = await api(await resolveConnection(options.apiUrl), `/api/v1/brief${query}`);
562
+ if (options.format === "json")
563
+ return printJson(brief);
564
+ process.stdout.write(brief.text);
565
+ },
566
+ },
567
+ examples: {
568
+ usage: "examples (plain questions with SQL that answers them, for the tables this workspace has)",
569
+ async run(args, options) {
570
+ if (args.length > 0)
571
+ throw new CliError("Usage: examples", exitCodes.usage);
572
+ const { examples } = await api(await resolveConnection(options.apiUrl), "/api/v1/examples");
573
+ if (options.format === "json")
574
+ return printJson({ examples });
575
+ for (const e of examples) {
576
+ console.log(`## ${e.id}\n\n${e.question}\nReads: ${e.needs.join(", ")}`);
577
+ if (e.notes)
578
+ console.log(`Note: ${e.notes}`);
579
+ console.log(`\n\`\`\`sql\n${e.sql}\n\`\`\`\n`);
580
+ }
581
+ },
582
+ },
554
583
  "content search": {
555
584
  usage: "content search [--text <words>] [--mode word|meaning] [--kind <kind>] [--platform <platform>] [--origin discovered|made] [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--sort recent|<scorecard column>] [--order desc|asc] [--limit <n>]",
556
585
  async run(args, options) {
@@ -672,6 +701,38 @@ const commands = {
672
701
  printPublication(publication, options.format);
673
702
  },
674
703
  },
704
+ "meta adset publish": {
705
+ usage: "meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>]",
706
+ async run(args, options) {
707
+ if (args.length !== 0 || options.plan === undefined || !options.idempotencyKey) {
708
+ throw new CliError("Usage: meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>]", exitCodes.usage);
709
+ }
710
+ const { adsetPublication } = await api(await resolveConnection(options.apiUrl), "/api/v1/meta/adsets", {
711
+ plan: await parseJson(options.plan, "--plan"),
712
+ idempotencyKey: options.idempotencyKey,
713
+ ...(options.run === undefined ? {} : { runId: options.run }),
714
+ });
715
+ printAdSetPublication(adsetPublication, options.format);
716
+ },
717
+ },
718
+ "meta adset show": {
719
+ usage: "meta adset show <adset-publication-id>",
720
+ async run(args, options) {
721
+ if (args.length !== 1)
722
+ throw new CliError("Usage: meta adset show <adset-publication-id>", exitCodes.usage);
723
+ const { adsetPublication } = await api(await resolveConnection(options.apiUrl), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}`);
724
+ printAdSetPublication(adsetPublication, options.format);
725
+ },
726
+ },
727
+ "meta adset reconcile": {
728
+ usage: "meta adset reconcile <adset-publication-id>",
729
+ async run(args, options) {
730
+ if (args.length !== 1)
731
+ throw new CliError("Usage: meta adset reconcile <adset-publication-id>", exitCodes.usage);
732
+ const { adsetPublication } = await api(await resolveConnection(options.apiUrl), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}/reconcile`, {});
733
+ printAdSetPublication(adsetPublication, options.format);
734
+ },
735
+ },
675
736
  "content unpublish": {
676
737
  usage: "content unpublish <deployment-id> [--force]",
677
738
  async run(args, options) {
@@ -726,7 +787,6 @@ const commands = {
726
787
  ...(start.result ? [{ field: "result", value: `${start.result.name} (${start.result.metaActionTypes.join(", ") || "no Meta source"})` }] : []),
727
788
  ...(quota ? [{ field: "ideas today", value: `${quota.creativeProposalsToday} of ${quota.ideasPerDay} used, ${quota.remainingToday} left` }] : []),
728
789
  { field: "brand profile", value: brandProfile ? `version ${brandProfile.version}, ${brandProfile.ageDays} days old` : "none yet" },
729
- ...start.memory.map((note) => ({ field: `memory ${note.key}`, value: note.value ? `${note.value.length} characters` : "empty" })),
730
790
  ];
731
791
  console.log(renderTable(["field", "value"], rows));
732
792
  },
@@ -814,36 +874,6 @@ const commands = {
814
874
  console.log(`\n${rows.length ? renderTable(Object.keys(rows[0]), rows) : "No P1 winners."}`);
815
875
  },
816
876
  },
817
- "memory get": {
818
- usage: "memory get [baselines|watch_items|changes_log|gotchas]",
819
- async run(args, options) {
820
- if (args.length > 1)
821
- throw new CliError("Usage: memory get [<note>]", exitCodes.usage);
822
- const connection = await resolveConnection(options.apiUrl);
823
- if (args[0]) {
824
- const { note } = await api(connection, `${agentPath(options)}/memory/${encodeURIComponent(args[0])}`);
825
- if (options.format === "json")
826
- return printJson({ note });
827
- return void process.stdout.write(note.value ? `${note.value.replace(/\n?$/, "\n")}` : "");
828
- }
829
- const { memory } = await api(connection, `${agentPath(options)}/memory`);
830
- if (options.format === "json")
831
- return printJson({ memory });
832
- console.log(memory.map((n) => `## ${n.key}${n.updatedAt ? ` (updated ${n.updatedAt})` : ""}\n\n${n.value || "(empty)"}`).join("\n\n"));
833
- },
834
- },
835
- "memory set": {
836
- usage: "memory set <note> [--file <path> | < note.md]",
837
- async run(args, options) {
838
- if (args.length !== 1)
839
- throw new CliError("Usage: memory set <note> [--file <path>]", exitCodes.usage);
840
- const value = await readText(options.file, "the note");
841
- const { note } = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/memory/${encodeURIComponent(args[0])}`, { value }, "PUT");
842
- if (options.format === "json")
843
- return printJson({ note });
844
- console.log(`Saved ${note.key} (${note.value.length} characters).`);
845
- },
846
- },
847
877
  "brand get": {
848
878
  usage: "brand get",
849
879
  async run(_args, options) {
@@ -918,11 +948,147 @@ const commands = {
918
948
  printTrail(trail, options.format);
919
949
  },
920
950
  },
951
+ "funnels show": {
952
+ usage: "funnels show [--version <n>] the offers and funnels map as text: the confirmed map (or that version) and the draft on hold",
953
+ async run(args, options) {
954
+ if (args.length > 0)
955
+ throw new CliError("Usage: funnels show [--version <n>]", exitCodes.usage);
956
+ const query = options.version === undefined ? "" : `?version=${wholeNumber(options.version, "--version")}`;
957
+ const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}${query}`);
958
+ if (options.format === "json")
959
+ return printJson(view);
960
+ process.stdout.write(view.text);
961
+ },
962
+ },
963
+ "funnels numbers": {
964
+ usage: "funnels numbers [--funnel <key>] [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] how many reached each funnel step and moved on from the one before",
965
+ async run(args, options) {
966
+ if (args.length > 0)
967
+ throw new CliError("Usage: funnels numbers [--funnel <key>] [--from <date>] [--to <date>]", exitCodes.usage);
968
+ const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}/numbers${reportQuery(options, { funnel: options.funnel })}`);
969
+ if (options.format === "json")
970
+ return printJson(view);
971
+ process.stdout.write(view.text);
972
+ },
973
+ },
974
+ "funnels evidence": {
975
+ usage: "funnels evidence what the data says the client sells: prices paid, buyer paths, GoHighLevel funnels, calendars and tags, webinars, pages, campaigns, past campaigns",
976
+ async run(args, options) {
977
+ if (args.length > 0)
978
+ throw new CliError("Usage: funnels evidence", exitCodes.usage);
979
+ const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}/evidence`);
980
+ if (options.format === "json")
981
+ return printJson(view);
982
+ process.stdout.write(view.text);
983
+ },
984
+ },
985
+ "funnels draft": {
986
+ usage: "funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] save a whole map; it counts at once when every step can be counted",
987
+ async run(args, options) {
988
+ const usage = "Usage: funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>]";
989
+ if (args.length > 0 || !options.file)
990
+ throw new CliError(usage, exitCodes.usage);
991
+ const map = await parseJson(`@${options.file}`, "--file");
992
+ const evidence = options.evidence === undefined ? undefined : await parseJson(options.evidence, "--evidence");
993
+ const connection = await resolveConnection(options.apiUrl);
994
+ const basedOn = options.basedOn === undefined ? null : wholeNumber(options.basedOn, "--based-on");
995
+ const saved = await api(connection, `${FUNNELS_PATH}/draft`, { map, note: options.note, evidence, based_on: basedOn }, "PUT");
996
+ if (options.format === "json")
997
+ return printJson(saved);
998
+ const based = basedOn === null ? "" : `, based on version ${basedOn}`;
999
+ console.log(saved.applied
1000
+ ? `Map version ${saved.version.version} is live${based}: it counts from now. A person can undo it in the app.`
1001
+ : `Saved version ${saved.version.version}${based}. It is on hold: it counts nothing until every step can be counted.`);
1002
+ if (saved.warnings.length > 0)
1003
+ console.log(`\n${saved.applied ? "Not in synced data yet" : "To fill in"}:\n${saved.warnings.map((w) => `- ${w.path}: ${w.message}`).join("\n")}`);
1004
+ if (saved.weak.length > 0)
1005
+ console.log(`\nNot backed up by synced data:\n${saved.weak.map((w) => `- ${w.path}: ${w.reason}`).join("\n")}`);
1006
+ },
1007
+ },
1008
+ "funnels versions": {
1009
+ usage: "funnels versions [--diff <a> <b>] every version of the map, or what changed from version a to b",
1010
+ async run(args, options) {
1011
+ const connection = await resolveConnection(options.apiUrl);
1012
+ if (options.diff !== undefined) {
1013
+ if (args.length !== 1)
1014
+ throw new CliError("Usage: funnels versions --diff <a> <b>", exitCodes.usage);
1015
+ const pair = [options.diff, args[0]].map((v) => wholeNumber(v, "--diff")).join(",");
1016
+ const diff = await api(connection, `${FUNNELS_PATH}/versions?diff=${pair}`);
1017
+ if (options.format === "json")
1018
+ return printJson(diff);
1019
+ process.stdout.write(diff.text);
1020
+ return;
1021
+ }
1022
+ if (args.length > 0)
1023
+ throw new CliError("Usage: funnels versions [--diff <a> <b>]", exitCodes.usage);
1024
+ const { versions } = await api(connection, `${FUNNELS_PATH}/versions`);
1025
+ if (options.format === "json")
1026
+ return printJson({ versions });
1027
+ if (versions.length === 0)
1028
+ return console.log("No map versions yet.");
1029
+ const rows = versions.map((v) => ({
1030
+ version: v.version,
1031
+ status: v.status,
1032
+ "created by": v.createdBy,
1033
+ "created at": v.createdAt,
1034
+ "confirmed by": v.confirmedBy,
1035
+ "confirmed at": v.confirmedAt,
1036
+ note: v.note && excerpt(v.note),
1037
+ }));
1038
+ console.log(renderTable(["version", "status", "created by", "created at", "confirmed by", "confirmed at", "note"], rows));
1039
+ },
1040
+ },
1041
+ facts: {
1042
+ usage: "facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json] settled facts about this workspace's data, to treat as true",
1043
+ async run(args, options) {
1044
+ if (args.length > 0)
1045
+ throw new CliError("Usage: facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json]", exitCodes.usage);
1046
+ const query = new URLSearchParams();
1047
+ if (options.kind)
1048
+ query.set("kind", options.kind);
1049
+ if (options.subject) {
1050
+ const colon = options.subject.indexOf(":");
1051
+ query.set("subject_type", colon === -1 ? options.subject : options.subject.slice(0, colon));
1052
+ if (colon !== -1 && colon < options.subject.length - 1)
1053
+ query.set("subject_ref", options.subject.slice(colon + 1));
1054
+ }
1055
+ if (options.status)
1056
+ query.set("status", options.status);
1057
+ const { facts } = await api(await resolveConnection(options.apiUrl), `${FACTS_PATH}${query.size ? `?${query}` : ""}`);
1058
+ if (options.format === "json")
1059
+ return printJson({ facts });
1060
+ if (facts.length === 0)
1061
+ return console.log("No facts.");
1062
+ console.log(renderTable(FACT_COLUMNS, facts.map(factRow)));
1063
+ },
1064
+ },
1065
+ "facts show": {
1066
+ usage: "facts show <id> one fact with its value and evidence",
1067
+ async run(args, options) {
1068
+ if (args.length !== 1)
1069
+ throw new CliError("Usage: facts show <id>", exitCodes.usage);
1070
+ const { fact } = await api(await resolveConnection(options.apiUrl), `${FACTS_PATH}/${encodeURIComponent(args[0])}`);
1071
+ if (options.format === "json")
1072
+ return printJson({ fact });
1073
+ printFields({ ...factRow(fact), summary: fact.summary, value: fact.value, evidence: fact.evidence, author: fact.author, "confirmed by": fact.confirmedBy, replaces: fact.replaces });
1074
+ },
1075
+ },
1076
+ "facts propose": {
1077
+ usage: "facts propose --file <fact.json> | < fact.json suggest a fact; a person confirms it in the app",
1078
+ async run(args, options) {
1079
+ if (args.length > 0)
1080
+ throw new CliError("Usage: facts propose --file <fact.json> | < fact.json", exitCodes.usage);
1081
+ const { fact } = await api(await resolveConnection(options.apiUrl), FACTS_PATH, await readToolInput(options));
1082
+ if (options.format === "json")
1083
+ return printJson({ fact });
1084
+ console.log(`Proposed fact ${fact.id}. A person confirms or rejects it in the app.`);
1085
+ },
1086
+ },
921
1087
  "plans create": {
922
- usage: "plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>' | @file.json] [--landing <sku | item-id> | --landing-url <url>] [--run <agent-run-id>]",
1088
+ usage: "plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>' | @file.json] [--landing <sku | item-id> | --landing-url <url>] [--funnel <key> --step <key>] [--run <agent-run-id>]",
923
1089
  async run(args, options) {
924
1090
  if (args.length > 0 || !options.name || !options.goal || !options.channel) {
925
- throw new CliError("Usage: plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>'] [--landing <sku> | --landing-url <url>]", exitCodes.usage);
1091
+ throw new CliError("Usage: plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>'] [--landing <sku> | --landing-url <url>] [--funnel <key> --step <key>]", exitCodes.usage);
926
1092
  }
927
1093
  const { plan } = await api(await resolveConnection(options.apiUrl), "/api/v1/launch-plans", {
928
1094
  name: options.name,
@@ -931,6 +1097,8 @@ const commands = {
931
1097
  ...(options.target === undefined ? {} : { target: await parseJson(options.target, "--target") }),
932
1098
  ...(options.landing === undefined ? {} : { landingItem: options.landing }),
933
1099
  ...(options.landingUrl === undefined ? {} : { landingUrl: options.landingUrl }),
1100
+ ...(options.funnel === undefined ? {} : { funnelKey: options.funnel }),
1101
+ ...(options.step === undefined ? {} : { stepKey: options.step }),
934
1102
  ...(options.run === undefined ? {} : { runId: options.run }),
935
1103
  });
936
1104
  printPlan(plan, options.format);
@@ -1054,9 +1222,9 @@ const commands = {
1054
1222
  },
1055
1223
  },
1056
1224
  "skill install": {
1057
- usage: "skill install [meta-ads|p3-refresh|<name>[@<version>]] [--project] [--codex]",
1225
+ usage: `skill install [${PLAYBOOK_CHOICES}|<name>[@<version>]] [--project] [--codex]`,
1058
1226
  async run(args, options) {
1059
- const usage = "Usage: skill install [meta-ads|p3-refresh|<name>[@<version>]] [--project] [--codex]";
1227
+ const usage = `Usage: skill install [${PLAYBOOK_CHOICES}|<name>[@<version>]] [--project] [--codex]`;
1060
1228
  const [which] = args;
1061
1229
  if (args.length > 1)
1062
1230
  throw new CliError(usage, exitCodes.usage);
@@ -1067,15 +1235,15 @@ const commands = {
1067
1235
  if (which === "meta-ads")
1068
1236
  return installAgentsMd(join(process.cwd(), "AGENTS.md"));
1069
1237
  if (!playbook)
1070
- throw new CliError("--codex installs a playbook: skill install meta-ads|p3-refresh --codex", exitCodes.usage);
1238
+ throw new CliError(`--codex installs a playbook: skill install ${PLAYBOOK_CHOICES} --codex`, exitCodes.usage);
1071
1239
  if (options.project)
1072
1240
  throw new CliError(`--codex installs ${playbook.name} for every Codex run, under CODEX_HOME; drop --project`, exitCodes.usage);
1073
- const path = await writeSkill(codexSkillsDir(), playbook.name, playbook.skill, []);
1241
+ const path = await writeSkill(codexSkillsDir(), playbook.name, playbook.skill, playbook.files, []);
1074
1242
  return console.log(`Installed the ${playbook.name} skill for Codex to ${path}.`);
1075
1243
  }
1076
- const [name, content] = playbook ? [playbook.name, playbook.skill] : [brand.slug, skill];
1244
+ const [name, content, files] = playbook ? [playbook.name, playbook.skill, playbook.files] : [brand.slug, skill, []];
1077
1245
  const { skillsDir, above } = claudeSkillsDir(options.project);
1078
- const path = await writeSkill(skillsDir, name, content, above);
1246
+ const path = await writeSkill(skillsDir, name, content, files, above);
1079
1247
  console.log(`Installed the ${name} skill to ${path}.`);
1080
1248
  },
1081
1249
  },
@@ -1088,7 +1256,7 @@ const commands = {
1088
1256
  if (options.format === "json")
1089
1257
  return printJson({ workers });
1090
1258
  if (workers.length === 0)
1091
- return console.log("No workers yet.");
1259
+ return console.log("No scheduled hands yet.");
1092
1260
  const rows = workers.map((w) => ({
1093
1261
  id: w.id,
1094
1262
  skill: w.skill,
@@ -1104,10 +1272,10 @@ const commands = {
1104
1272
  },
1105
1273
  },
1106
1274
  "worker runs": {
1107
- usage: "worker runs <worker-id | skill> [--limit <n>]",
1275
+ usage: "worker runs <hand-id | skill> [--limit <n>]",
1108
1276
  async run(args, options) {
1109
1277
  if (args.length !== 1)
1110
- throw new CliError("Usage: worker runs <worker-id | skill> [--limit <n>]", exitCodes.usage);
1278
+ throw new CliError("Usage: worker runs <hand-id | skill> [--limit <n>]", exitCodes.usage);
1111
1279
  const connection = await resolveConnection(options.apiUrl);
1112
1280
  const id = await workerId(connection, args[0]);
1113
1281
  const query = options.limit === undefined ? "" : `?limit=${encodeURIComponent(options.limit)}`;
@@ -1120,10 +1288,10 @@ const commands = {
1120
1288
  },
1121
1289
  },
1122
1290
  "worker run-now": {
1123
- usage: "worker run-now <worker-id | skill>",
1291
+ usage: "worker run-now <hand-id | skill>",
1124
1292
  async run(args, options) {
1125
1293
  if (args.length !== 1)
1126
- throw new CliError("Usage: worker run-now <worker-id | skill>", exitCodes.usage);
1294
+ throw new CliError("Usage: worker run-now <hand-id | skill>", exitCodes.usage);
1127
1295
  const connection = await resolveConnection(options.apiUrl);
1128
1296
  const id = await workerId(connection, args[0]);
1129
1297
  const queued = await api(connection, `/api/v1/workers/${encodeURIComponent(id)}/run-now`, {});
@@ -1140,7 +1308,7 @@ const commands = {
1140
1308
  const { promoted } = await changeCandidate("promote", args, options);
1141
1309
  if (options.format === "json")
1142
1310
  return printJson({ promoted });
1143
- console.log(`${promoted.skill} version ${promoted.version} is now in use on ${promoted.moved} worker${promoted.moved === 1 ? "" : "s"}.`);
1311
+ console.log(`${promoted.skill} version ${promoted.version} is now in use on ${promoted.moved} hand${promoted.moved === 1 ? "" : "s"}.`);
1144
1312
  },
1145
1313
  },
1146
1314
  "worker drop": {
@@ -1175,11 +1343,11 @@ const commands = {
1175
1343
  },
1176
1344
  },
1177
1345
  docs: {
1178
- usage: "docs [meta-ads|p3-refresh]",
1346
+ usage: `docs [${PLAYBOOK_CHOICES}]`,
1179
1347
  async run(args) {
1180
1348
  const [which] = args;
1181
1349
  if (args.length > 1 || (which !== undefined && !isPlaybook(which)))
1182
- throw new CliError("Usage: docs [meta-ads|p3-refresh]", exitCodes.usage);
1350
+ throw new CliError(`Usage: docs [${PLAYBOOK_CHOICES}]`, exitCodes.usage);
1183
1351
  process.stdout.write(which === undefined ? guide : playbooks[which].text);
1184
1352
  },
1185
1353
  },
@@ -1208,10 +1376,6 @@ async function withRunner(options, work) {
1208
1376
  process.off("SIGTERM", onSignal);
1209
1377
  }
1210
1378
  }
1211
- const playbooks = {
1212
- "meta-ads": { name: META_ADS_SKILL_NAME, skill: metaAdsSkill, text: metaAdsPlaybook },
1213
- "p3-refresh": { name: P3_REFRESH_SKILL_NAME, skill: p3RefreshSkill, text: p3RefreshPlaybook },
1214
- };
1215
1379
  const isPlaybook = (name) => Object.hasOwn(playbooks, name);
1216
1380
  const codexSkillsDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "skills");
1217
1381
  /** Claude Code's skills folder, in the user's home or (with --project) the current folder. */
@@ -1232,15 +1396,20 @@ async function installLibrarySkill(ref, options) {
1232
1396
  const path = await installSkillFolder(skillsDir, download.name, files, above);
1233
1397
  console.log(`installed ${download.name} version ${download.version} to ${path}`);
1234
1398
  }
1235
- /** Writes <skillsDir>/<name>/SKILL.md, refusing to follow a symlink at any of `above` or the skill's own paths. */
1236
- async function writeSkill(skillsDir, name, content, above) {
1237
- const path = await inRunFolder(join(skillsDir, name, "SKILL.md"));
1238
- const linked = await firstSymlink([...above, join(skillsDir, name), path]);
1399
+ /**
1400
+ * Writes <skillsDir>/<name>/SKILL.md and any files beside it, refusing to follow a symlink at any of `above` or the
1401
+ * skill's own paths. Returns the SKILL.md path.
1402
+ */
1403
+ async function writeSkill(skillsDir, name, content, files, above) {
1404
+ const written = [{ name: "SKILL.md", content }, ...files];
1405
+ const paths = await Promise.all(written.map((f) => inRunFolder(join(skillsDir, name, f.name))));
1406
+ const linked = await firstSymlink([...above, join(skillsDir, name), ...paths]);
1239
1407
  if (linked)
1240
1408
  throw new CliError(`Refusing to write through the symlink at ${linked}. Remove it and try again.`, exitCodes.error);
1241
- await mkdir(dirname(path), { recursive: true });
1242
- await writeFileAtomic(path, content, 0o644);
1243
- return path;
1409
+ await mkdir(join(skillsDir, name), { recursive: true });
1410
+ for (const [i, file] of written.entries())
1411
+ await writeFileAtomic(paths[i], file.content, 0o644);
1412
+ return paths[0];
1244
1413
  }
1245
1414
  const SOURCE_RUNS_SHOWN = 10;
1246
1415
  /** What the source is retrying: "the whole sync since <time>", and each failing part as "<part> since <time>". */
@@ -1281,6 +1450,13 @@ function reportQuery(options, extra) {
1281
1450
  return query.size ? `?${query}` : "";
1282
1451
  }
1283
1452
  /** The rows, then the totals, which are one row per currency. */
1453
+ /** What confirmed leave-out rules took out of `figures`, one line per rule and currency. */
1454
+ function printLeftOut(leftOut, figures) {
1455
+ if (!leftOut?.length)
1456
+ return;
1457
+ const rows = leftOut.flatMap((l) => l.amounts.map((a) => ({ fact: l.fact_id, currency: a.currency, revenue: a.revenue, sales: a.sales, rule: l.summary })));
1458
+ console.log(`\nLeft out of ${figures} by confirmed leave-out rules:\n${renderTable(["fact", "currency", "revenue", "sales", "rule"], rows)}`);
1459
+ }
1284
1460
  function printReport(title, report, labels, numbers) {
1285
1461
  if (report.rows.length === 0)
1286
1462
  return console.log(`No numbers from ${report.from} to ${report.to}.`);
@@ -1290,6 +1466,11 @@ function printReport(title, report, labels, numbers) {
1290
1466
  console.log(renderTable(["currency", ...numbers], report.totals));
1291
1467
  }
1292
1468
  const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
1469
+ const FACTS_PATH = "/api/v1/facts";
1470
+ const FACT_COLUMNS = ["id", "kind", "subject", "key", "status", "summary"];
1471
+ function factRow(f) {
1472
+ return { id: f.id, kind: f.kind, subject: `${f.subjectType}:${f.subjectRef}`, key: f.key, status: f.status, summary: excerpt(f.summary, 80) };
1473
+ }
1293
1474
  const PROPOSAL_COLUMNS = ["id", "kind", "status", "target", "change", "reason"];
1294
1475
  function proposalChange(p) {
1295
1476
  if (p.kind === "creative")
@@ -1331,7 +1512,7 @@ Options:
1331
1512
  --format table|json output format (default: table)
1332
1513
  --api-url <url> API base URL
1333
1514
  --wait <seconds> how long tools run and tools get wait for a run (default: ${DEFAULT_WAIT_SECONDS}; 0 returns at once)
1334
- --agent <kind> which agent the agent and memory commands use: meta_ads (default) or p3_refresh; in a worker run, skill
1515
+ --agent <kind> which agent the agent commands use: meta_ads (default) or p3_refresh; in a scheduled hand's run, skill
1335
1516
  (the run's skill's agent) is the default
1336
1517
  --kind <kind> what sends to a new intake address: generic (default), ghl_workflow, aloware, webinarfuel, stealth_seminar or mailgun
1337
1518
  --retention-days <n> how many days intake create keeps each event (default: 90)
@@ -1340,18 +1521,21 @@ Options:
1340
1521
  for warehouse schema, a table to describe in full; repeat for several
1341
1522
  --summary warehouse schema: one short row per table (kind, source, column count, summary)
1342
1523
  --search <words> warehouse schema: describe in full the tables whose name, description or columns mention a word
1343
- --out <path> the file, or existing folder, creatives download saves to (default: the current folder)
1524
+ --out <path> the file, or existing folder, creatives download and warehouse query save to; warehouse query
1525
+ writes CSV for a .csv path, otherwise JSON
1344
1526
  --open open the file creatives download saved
1345
1527
  --media with content pull, also save each item's files and HTML under media/ in the folder
1346
1528
  --full with content pull, fetch every item, not only what changed since the last pull (brings new numbers)
1347
1529
  --force content unpublish: take the page down even while delivering Meta ads link to it
1530
+ --plan <json> meta adset publish: the ad set to make, {"kind": "copy", ...} or {"kind": "create", ...}
1348
1531
  --yes confirm intake delete or import remove, which delete saved data for good
1349
- --from <YYYY-MM-DD> first day funnel, campaigns and money cover (default: 29 days before --to)
1350
- --to <YYYY-MM-DD> last day funnel, campaigns and money cover (default: today in the workspace reporting time zone)
1532
+ --from <YYYY-MM-DD> first day funnel, campaigns, money and brief cover (default: 29 days before --to)
1533
+ --to <YYYY-MM-DD> last day funnel, campaigns, money and brief cover (default: today in the workspace reporting time zone)
1351
1534
  --grain <grain> how funnel and money group days: day (default), week (starting Monday) or month
1352
- --channel <channel> only this channel's campaigns: meta, google, affiliate, a utm_source, or unknown;
1535
+ --channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
1353
1536
  for content track-link, where the link goes: facebook, email, sms or web (default)
1354
- --campaign <id> the campaign content track-link puts in utm_campaign
1537
+ --campaign <id> the campaign content track-link puts in utm_campaign; with brief, the one campaign it covers
1538
+ --top <n> how many best ads, worst ads, pages and campaigns brief shows (default: 5, at most 20)
1355
1539
  --body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
1356
1540
  --file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several
1357
1541
  --transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
@@ -1362,6 +1546,14 @@ Options:
1362
1546
  --run <id> the agent run making the content (content), or the tool run to record (creatives create)
1363
1547
  --platform, --external-id where content status launched says it was published, and that platform's id for it
1364
1548
  --notes <text> what changed in the version skill publish adds
1549
+ --version <n> the map version funnels show prints (default: the confirmed one)
1550
+ --based-on <n> the map version funnels draft was written from; refused when someone saved since
1551
+ (leave it out only for the first map, when there is neither a map nor a draft)
1552
+ --evidence <json | @file.json> what funnels draft was written from: counts and ids, never names, emails or phones
1553
+ --diff <a> <b> with funnels versions, what changed from version a to version b
1554
+ --funnel <key> only this funnel's steps in funnels numbers (default: every funnel in the confirmed map);
1555
+ with plans create and --step, the funnel whose step the push feeds
1556
+ --step <key> with plans create, the step of --funnel the push feeds, as on the confirmed map
1365
1557
  --harness <list> which agents skill publish marks the skill for: claude, codex, or claude,codex (default)
1366
1558
  --text <words> words content search finds in a subject, headline, body or video transcript
1367
1559
  --mode word|meaning how content search matches --text: word (default, the words themselves) or meaning (close in meaning, nearest first; no --sort)
@@ -1372,13 +1564,13 @@ Options:
1372
1564
  --order desc|asc content search's sort direction (default: desc, best or newest first)
1373
1565
  --limit <n> how many results content search, creatives list and campaigns return
1374
1566
  --max-seconds <n> stop a runner's run after this many seconds, if that comes before its deadline
1375
- --workspace <slug> the workspace whose worker worker promote and worker drop change
1567
+ --workspace <slug> for worker promote and worker drop: the workspace whose hand they change
1376
1568
  -h, --help show this help
1377
1569
 
1378
1570
  skill publish, skill list and skill install <name> use the skill library, which takes the operator token in
1379
1571
  ${envPrefix}_OPERATOR_TOKEN instead of an API key, as do worker promote and worker drop. worker promote makes the
1380
- version a worker is trying the one in use, there and on every worker of the skill on an older version; worker drop
1381
- stops trying it.
1572
+ version a scheduled hand is trying the one in use, there and on every hand of the skill on an older version; worker
1573
+ drop stops trying it.
1382
1574
 
1383
1575
  runner and runner once take the runner token in ${RUNNER_TOKEN_ENV}. ${HARNESS_COMMAND_ENV}, if set, names a program
1384
1576
  the runner starts instead of Claude Code or Codex.
@@ -1391,6 +1583,7 @@ function parseOptions(argv) {
1391
1583
  allowPositionals: true,
1392
1584
  options: {
1393
1585
  format: { type: "string", default: "table" },
1586
+ json: { type: "boolean", default: false },
1394
1587
  "api-url": { type: "string" },
1395
1588
  file: { type: "string", multiple: true },
1396
1589
  project: { type: "boolean", default: false },
@@ -1421,6 +1614,7 @@ function parseOptions(argv) {
1421
1614
  grain: { type: "string" },
1422
1615
  channel: { type: "string" },
1423
1616
  campaign: { type: "string" },
1617
+ top: { type: "string" },
1424
1618
  name: { type: "string" },
1425
1619
  subject: { type: "string" },
1426
1620
  preview: { type: "string" },
@@ -1451,6 +1645,11 @@ function parseOptions(argv) {
1451
1645
  position: { type: "string" },
1452
1646
  plan: { type: "string" },
1453
1647
  version: { type: "string" },
1648
+ "based-on": { type: "string" },
1649
+ evidence: { type: "string" },
1650
+ diff: { type: "string" },
1651
+ funnel: { type: "string" },
1652
+ step: { type: "string" },
1454
1653
  workspace: { type: "string" },
1455
1654
  "max-seconds": { type: "string" },
1456
1655
  help: { type: "boolean", short: "h", default: false },
@@ -1470,7 +1669,7 @@ function parseOptions(argv) {
1470
1669
  positionals,
1471
1670
  help: values.help,
1472
1671
  options: {
1473
- format: values.format,
1672
+ format: values.json ? "json" : values.format,
1474
1673
  apiUrl: values["api-url"],
1475
1674
  file: values.file?.at(-1),
1476
1675
  files: values.file ?? [],
@@ -1503,6 +1702,7 @@ function parseOptions(argv) {
1503
1702
  grain: values.grain,
1504
1703
  channel: values.channel,
1505
1704
  campaign: values.campaign,
1705
+ top: values.top,
1506
1706
  name: values.name,
1507
1707
  subject: values.subject,
1508
1708
  preview: values.preview,
@@ -1533,18 +1733,26 @@ function parseOptions(argv) {
1533
1733
  position: values.position,
1534
1734
  plan: values.plan,
1535
1735
  version: values.version,
1736
+ basedOn: values["based-on"],
1737
+ evidence: values.evidence,
1738
+ diff: values.diff,
1739
+ funnel: values.funnel,
1740
+ step: values.step,
1536
1741
  workspace: values.workspace,
1537
1742
  maxSeconds,
1538
1743
  },
1539
1744
  };
1540
1745
  }
1541
- /** Promotes or drops the candidate version of a workspace's worker for a skill, with the operator token. */
1746
+ /** Promotes or drops the candidate version of a workspace's scheduled hand for a skill, with the operator token. */
1542
1747
  async function changeCandidate(change, args, options) {
1543
1748
  if (args.length !== 1 || !options.workspace)
1544
1749
  throw new CliError(`Usage: worker ${change} <skill> --workspace <slug>`, exitCodes.usage);
1545
1750
  return api(await operatorConnection(options.apiUrl), `/api/operator/workers/${change}`, { workspace: options.workspace, skill: args[0] });
1546
1751
  }
1547
1752
  function findCommand(positionals) {
1753
+ const three = positionals.slice(0, 3).join(" ");
1754
+ if (Object.hasOwn(commands, three))
1755
+ return { command: commands[three], args: positionals.slice(3) };
1548
1756
  const two = positionals.slice(0, 2).join(" ");
1549
1757
  if (Object.hasOwn(commands, two))
1550
1758
  return { command: commands[two], args: positionals.slice(2) };
@@ -1740,6 +1948,14 @@ async function uploadContentFile(connection, data, contentType) {
1740
1948
  await putSigned(upload.url, data, contentType);
1741
1949
  return upload.uploadId;
1742
1950
  }
1951
+ /** What the CLI says after a link: that its platform no longer lists it, or that it was edited there. */
1952
+ function linkNote(link) {
1953
+ if (link.removedAt)
1954
+ return ` (no longer on ${link.platform})`;
1955
+ if (link.platformChangedAt)
1956
+ return ` (changed on ${link.platform} since it was published)`;
1957
+ return "";
1958
+ }
1743
1959
  function printContent(content, format) {
1744
1960
  if (format === "json")
1745
1961
  return printJson(content);
@@ -1758,7 +1974,7 @@ function printContent(content, format) {
1758
1974
  html: version.htmlUrl,
1759
1975
  files: version.media.length ? version.media.map((m) => m.url).join(" ") : null,
1760
1976
  links: item.links.length
1761
- ? item.links.map((l) => `${l.platform} ${l.objectType} ${l.externalId}${l.removedAt ? ` (no longer on ${l.platform})` : ""}`).join(", ")
1977
+ ? item.links.map((l) => `${l.platform} ${l.objectType} ${l.externalId}${linkNote(l)}`).join(", ")
1762
1978
  : null,
1763
1979
  by: version.capturedBy,
1764
1980
  note: version.note,
@@ -1769,10 +1985,20 @@ function printContent(content, format) {
1769
1985
  deployments: content.deployments?.length ? content.deployments.map(deploymentLine).join(", ") : null,
1770
1986
  versions: content.versions && content.versions.length > 1 ? content.versions.length : null,
1771
1987
  transcript: content.transcripts?.map((t) => (t.text ? excerpt(t.text, 80) : t.status)).join(" | ") || null,
1988
+ description: descriptionLine(content.descriptions?.find((d) => d.version === version.version)),
1772
1989
  ...scoreFields(content.scorecards ?? []),
1773
1990
  };
1774
1991
  printFields(rows);
1775
1992
  }
1993
+ /** The current version's picture description in one line: its kind, and the words printed on it. */
1994
+ function descriptionLine(d) {
1995
+ if (!d)
1996
+ return null;
1997
+ if (!d.description)
1998
+ return d.status;
1999
+ const words = d.description.on_image_text.trim();
2000
+ return words ? `${d.description.visual_kind}: "${excerpt(words, 80)}"` : d.description.visual_kind;
2001
+ }
1776
2002
  function printContentDiff(diff) {
1777
2003
  const stamp = (sign, v) => `${sign} version ${v.version}, ${v.capturedAt} by ${v.capturedBy}${v.note ? `: ${v.note}` : ""}`;
1778
2004
  const value = (v) => (typeof v === "string" ? v : JSON.stringify(v));
@@ -1794,6 +2020,10 @@ function printContentDiff(diff) {
1794
2020
  lines.push("", "No changes.");
1795
2021
  console.log(lines.join("\n"));
1796
2022
  }
2023
+ function feedsText(funnel) {
2024
+ const sells = funnel.sellsOffer === null ? "" : `, which sells ${funnel.sellsOffer.name}`;
2025
+ return `step ${funnel.step.position} (${funnel.step.name}) of ${funnel.name}${sells}`;
2026
+ }
1797
2027
  const planPath = (id) => `/api/v1/launch-plans/${encodeURIComponent(id)}`;
1798
2028
  function printPlan(plan, format) {
1799
2029
  if (format === "json")
@@ -1808,6 +2038,9 @@ function printPlan(plan, format) {
1808
2038
  status: plan.status,
1809
2039
  next: plan.moves.length ? plan.moves.join(", ") : null,
1810
2040
  by: plan.createdBy,
2041
+ feeds: plan.funnel && feedsText(plan.funnel),
2042
+ "funnel note": plan.funnelNote,
2043
+ "funnel steps": plan.funnel?.steps.map((s) => `${s.position}. ${s.name}`).join(", "),
1811
2044
  });
1812
2045
  console.log("");
1813
2046
  if (plan.items.length === 0)
@@ -1816,12 +2049,12 @@ function printPlan(plan, format) {
1816
2049
  }
1817
2050
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1818
2051
  const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
1819
- /** The workspace's worker for the skill named `ref`, else the worker whose id it is: a skill's name may look like a uuid. */
2052
+ /** The workspace's scheduled hand for the skill named `ref`, else the one whose id it is: a skill's name may look like a uuid. */
1820
2053
  async function workerId(connection, ref) {
1821
2054
  const { workers } = await api(connection, "/api/v1/workers");
1822
2055
  const found = workers.find((w) => w.skill === ref) ?? workers.find((w) => w.id === ref.toLowerCase());
1823
2056
  if (!found)
1824
- throw new CliError(`unknown_worker: no worker with skill or id ${JSON.stringify(ref)} in this workspace`, exitCodes.error);
2057
+ throw new CliError(`unknown_worker: no scheduled hand with skill or id ${JSON.stringify(ref)} in this workspace`, exitCodes.error);
1825
2058
  return found.id;
1826
2059
  }
1827
2060
  function workerRunRow(r) {
@@ -1921,6 +2154,27 @@ function printPublication(publication, format) {
1921
2154
  finished: publication.finishedAt,
1922
2155
  });
1923
2156
  }
2157
+ function printAdSetPublication(publication, format) {
2158
+ if (format === "json")
2159
+ return printJson({ adsetPublication: publication });
2160
+ const { verification, budget } = publication;
2161
+ printFields({
2162
+ "adset publication": publication.id,
2163
+ kind: publication.kind,
2164
+ account: publication.accountId,
2165
+ campaign: publication.campaignId,
2166
+ source: publication.sourceAdsetId,
2167
+ name: publication.name,
2168
+ "external id": publication.externalId,
2169
+ status: publication.status,
2170
+ error: publication.error,
2171
+ verified: verification && (verification.ok ? "yes" : `no: ${verification.problems.join("; ")}`),
2172
+ budget: budget && (budget.dailyBudget !== null ? `${budget.dailyBudget} a day` : budget.lifetimeBudget !== null ? `${budget.lifetimeBudget} lifetime` : "the campaign's"),
2173
+ recorded: publication.recordedAt,
2174
+ sent: publication.sentAt,
2175
+ finished: publication.finishedAt,
2176
+ });
2177
+ }
1924
2178
  async function readToolInput(options) {
1925
2179
  let text;
1926
2180
  if (options.input !== undefined)
@@ -2047,6 +2301,53 @@ function decodeLinkPath(encoded) {
2047
2301
  throw new CliError(`"${excerpt(encoded)}" is not a valid asset link.`, exitCodes.usage);
2048
2302
  }
2049
2303
  }
2304
+ async function saveFile(path, content) {
2305
+ await writeFileAtomic(path, content, 0o644).catch((error) => {
2306
+ if (error.code === "ENOENT")
2307
+ throw new CliError(`No folder at ${dirname(path)} to save into.`, exitCodes.usage);
2308
+ throw error;
2309
+ });
2310
+ }
2311
+ /** Saves every row the server returned: CSV for a `.csv` path, otherwise the response body as it came. */
2312
+ async function saveQueryResult(text, out, format) {
2313
+ const path = await savePath(out, "results.json");
2314
+ const result = queryResultWithExactNumbers(text);
2315
+ await saveFile(path, extname(path).toLowerCase() === ".csv" ? toCsv(text) : text);
2316
+ if (format === "json")
2317
+ printJson({ path: resolve(path), rowCount: result.rows.length, truncated: result.truncated });
2318
+ else
2319
+ console.log(`Saved ${rowCount(result.rows.length)} to ${path}`);
2320
+ warnIfTruncated(result);
2321
+ }
2322
+ /** Top-level numbers keep the digits the server sent, so a numeric column is never rounded. */
2323
+ function queryResultWithExactNumbers(text) {
2324
+ const result = JSON.parse(text);
2325
+ const exact = JSON.parse(text, keepNumberDigits);
2326
+ const rows = result.rows.map((row, i) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, typeof v === "number" ? exact.rows[i][k] : v])));
2327
+ return { ...result, rows };
2328
+ }
2329
+ function rowCount(n) {
2330
+ return `${n.toLocaleString("en-US")} ${n === 1 ? "row" : "rows"}`;
2331
+ }
2332
+ function warnIfTruncated(result) {
2333
+ if (result.truncated)
2334
+ console.error(`Results were cut off (${result.truncated}). Aggregate in SQL or add LIMIT.`);
2335
+ }
2336
+ /** Every number keeps the digits the server sent, nested ones too, and json columns are written as JSON text. */
2337
+ function toCsv(text) {
2338
+ const result = JSON.parse(text, keepRawNumbers);
2339
+ const json = new Set(result.columns.filter((c) => c.type === "json" || c.type === "jsonb").map((c) => c.name));
2340
+ const field = (name, value) => (value != null && json.has(name) ? JSON.stringify(value) : cell(value));
2341
+ const names = result.columns.map((c) => c.name);
2342
+ const line = (values) => values.map(csvField).join(",");
2343
+ return [line(names), ...result.rows.map((row) => line(names.map((name) => field(name, row[name]))))].map((l) => `${l}\n`).join("");
2344
+ }
2345
+ function keepRawNumbers(_key, value, context) {
2346
+ return typeof value === "number" && context?.source !== undefined ? JSON.rawJSON(context.source) : value;
2347
+ }
2348
+ function csvField(value) {
2349
+ return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
2350
+ }
2050
2351
  /** `--out` names the file, or an existing folder to save into under the default name. */
2051
2352
  async function savePath(out, fileName) {
2052
2353
  if (out === undefined)
@@ -2074,6 +2375,13 @@ function openFile(path) {
2074
2375
  child.unref();
2075
2376
  }
2076
2377
  const INTAKE_PATH = "/api/v1/intake-endpoints";
2378
+ const FUNNELS_PATH = "/api/v1/funnels";
2379
+ function wholeNumber(value, flag) {
2380
+ const n = Number(value);
2381
+ if (!Number.isInteger(n) || n < 1)
2382
+ throw new CliError(`${flag} must be a whole number from 1.`, exitCodes.usage);
2383
+ return n;
2384
+ }
2077
2385
  const intakePath = (id) => `${INTAKE_PATH}/${encodeURIComponent(id)}`;
2078
2386
  function parseRetentionDays(text) {
2079
2387
  const days = Number(text);