@wrangled/cli 0.2.0 → 0.3.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/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: {
@@ -142,6 +140,9 @@ const commands = {
142
140
  printReport(`Money by ${money.grain}`, money, ["period", "currency"], MONEY_NUMBERS);
143
141
  if (money.by_source.length > 0)
144
142
  console.log(`\nMoney in by payment source:\n${renderTable(["source", "currency", "gross", "refunds", "net", "orders"], money.by_source)}`);
143
+ if (money.store_orders_in_stripe?.length) {
144
+ console.log(`\nStore orders paid through Stripe, counted under stripe:\n${renderTable(["source", "currency", "orders", "total"], money.store_orders_in_stripe)}`);
145
+ }
145
146
  if (money.refund_days.length > 0)
146
147
  console.log(`\nBiggest refund days:\n${renderTable(["date", "currency", "refunds"], money.refund_days)}`);
147
148
  },
@@ -258,8 +259,9 @@ const commands = {
258
259
  const missing = source.missingAccess ?? [];
259
260
  if (missing.length > 0)
260
261
  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)}`);
262
+ const tables = source.tables.map((t) => ({ ...t, rows: t.rowsEstimated ? `about ${t.rows}` : t.rows }));
263
+ if (tables.length > 0)
264
+ console.log(`\n${renderTable(["name", "rows", "earliest", "latest"], tables)}`);
263
265
  if (runs.length === 0)
264
266
  return console.log("\nNo sync runs yet.");
265
267
  const runRows = runs.map((r) => ({ ...r, error: r.error && excerpt(r.error) }));
@@ -479,11 +481,7 @@ const commands = {
479
481
  const target = await downloadTarget(args[0], connection);
480
482
  const { bytes, contentType } = await apiDownload(connection, `${ASSET_ROUTE}${target.assetPath.split("/").map(encodeURIComponent).join("/")}`);
481
483
  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
- });
484
+ await saveFile(path, bytes);
487
485
  if (options.open)
488
486
  openFile(resolve(path));
489
487
  if (options.format === "json")
@@ -551,6 +549,34 @@ const commands = {
551
549
  process.stdout.write(guide.text);
552
550
  },
553
551
  },
552
+ brief: {
553
+ 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)",
554
+ async run(args, options) {
555
+ if (args.length > 0)
556
+ throw new CliError("Usage: brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id>] [--top <n>]", exitCodes.usage);
557
+ const query = reportQuery(options, { channel: options.channel, campaignId: options.campaign, top: options.top });
558
+ const brief = await api(await resolveConnection(options.apiUrl), `/api/v1/brief${query}`);
559
+ if (options.format === "json")
560
+ return printJson(brief);
561
+ process.stdout.write(brief.text);
562
+ },
563
+ },
564
+ examples: {
565
+ usage: "examples (plain questions with SQL that answers them, for the tables this workspace has)",
566
+ async run(args, options) {
567
+ if (args.length > 0)
568
+ throw new CliError("Usage: examples", exitCodes.usage);
569
+ const { examples } = await api(await resolveConnection(options.apiUrl), "/api/v1/examples");
570
+ if (options.format === "json")
571
+ return printJson({ examples });
572
+ for (const e of examples) {
573
+ console.log(`## ${e.id}\n\n${e.question}\nReads: ${e.needs.join(", ")}`);
574
+ if (e.notes)
575
+ console.log(`Note: ${e.notes}`);
576
+ console.log(`\n\`\`\`sql\n${e.sql}\n\`\`\`\n`);
577
+ }
578
+ },
579
+ },
554
580
  "content search": {
555
581
  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
582
  async run(args, options) {
@@ -672,6 +698,38 @@ const commands = {
672
698
  printPublication(publication, options.format);
673
699
  },
674
700
  },
701
+ "meta adset publish": {
702
+ usage: "meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>]",
703
+ async run(args, options) {
704
+ if (args.length !== 0 || options.plan === undefined || !options.idempotencyKey) {
705
+ throw new CliError("Usage: meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>]", exitCodes.usage);
706
+ }
707
+ const { adsetPublication } = await api(await resolveConnection(options.apiUrl), "/api/v1/meta/adsets", {
708
+ plan: await parseJson(options.plan, "--plan"),
709
+ idempotencyKey: options.idempotencyKey,
710
+ ...(options.run === undefined ? {} : { runId: options.run }),
711
+ });
712
+ printAdSetPublication(adsetPublication, options.format);
713
+ },
714
+ },
715
+ "meta adset show": {
716
+ usage: "meta adset show <adset-publication-id>",
717
+ async run(args, options) {
718
+ if (args.length !== 1)
719
+ throw new CliError("Usage: meta adset show <adset-publication-id>", exitCodes.usage);
720
+ const { adsetPublication } = await api(await resolveConnection(options.apiUrl), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}`);
721
+ printAdSetPublication(adsetPublication, options.format);
722
+ },
723
+ },
724
+ "meta adset reconcile": {
725
+ usage: "meta adset reconcile <adset-publication-id>",
726
+ async run(args, options) {
727
+ if (args.length !== 1)
728
+ throw new CliError("Usage: meta adset reconcile <adset-publication-id>", exitCodes.usage);
729
+ const { adsetPublication } = await api(await resolveConnection(options.apiUrl), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}/reconcile`, {});
730
+ printAdSetPublication(adsetPublication, options.format);
731
+ },
732
+ },
675
733
  "content unpublish": {
676
734
  usage: "content unpublish <deployment-id> [--force]",
677
735
  async run(args, options) {
@@ -726,7 +784,6 @@ const commands = {
726
784
  ...(start.result ? [{ field: "result", value: `${start.result.name} (${start.result.metaActionTypes.join(", ") || "no Meta source"})` }] : []),
727
785
  ...(quota ? [{ field: "ideas today", value: `${quota.creativeProposalsToday} of ${quota.ideasPerDay} used, ${quota.remainingToday} left` }] : []),
728
786
  { 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
787
  ];
731
788
  console.log(renderTable(["field", "value"], rows));
732
789
  },
@@ -814,36 +871,6 @@ const commands = {
814
871
  console.log(`\n${rows.length ? renderTable(Object.keys(rows[0]), rows) : "No P1 winners."}`);
815
872
  },
816
873
  },
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
874
  "brand get": {
848
875
  usage: "brand get",
849
876
  async run(_args, options) {
@@ -918,11 +945,147 @@ const commands = {
918
945
  printTrail(trail, options.format);
919
946
  },
920
947
  },
948
+ "funnels show": {
949
+ usage: "funnels show [--version <n>] the offers and funnels map as text: the confirmed map (or that version) and the draft on hold",
950
+ async run(args, options) {
951
+ if (args.length > 0)
952
+ throw new CliError("Usage: funnels show [--version <n>]", exitCodes.usage);
953
+ const query = options.version === undefined ? "" : `?version=${wholeNumber(options.version, "--version")}`;
954
+ const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}${query}`);
955
+ if (options.format === "json")
956
+ return printJson(view);
957
+ process.stdout.write(view.text);
958
+ },
959
+ },
960
+ "funnels numbers": {
961
+ 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",
962
+ async run(args, options) {
963
+ if (args.length > 0)
964
+ throw new CliError("Usage: funnels numbers [--funnel <key>] [--from <date>] [--to <date>]", exitCodes.usage);
965
+ const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}/numbers${reportQuery(options, { funnel: options.funnel })}`);
966
+ if (options.format === "json")
967
+ return printJson(view);
968
+ process.stdout.write(view.text);
969
+ },
970
+ },
971
+ "funnels evidence": {
972
+ usage: "funnels evidence what the data says the client sells: prices paid, buyer paths, GoHighLevel funnels, calendars and tags, webinars, pages, campaigns, past campaigns",
973
+ async run(args, options) {
974
+ if (args.length > 0)
975
+ throw new CliError("Usage: funnels evidence", exitCodes.usage);
976
+ const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}/evidence`);
977
+ if (options.format === "json")
978
+ return printJson(view);
979
+ process.stdout.write(view.text);
980
+ },
981
+ },
982
+ "funnels draft": {
983
+ 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",
984
+ async run(args, options) {
985
+ const usage = "Usage: funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>]";
986
+ if (args.length > 0 || !options.file)
987
+ throw new CliError(usage, exitCodes.usage);
988
+ const map = await parseJson(`@${options.file}`, "--file");
989
+ const evidence = options.evidence === undefined ? undefined : await parseJson(options.evidence, "--evidence");
990
+ const connection = await resolveConnection(options.apiUrl);
991
+ const basedOn = options.basedOn === undefined ? null : wholeNumber(options.basedOn, "--based-on");
992
+ const saved = await api(connection, `${FUNNELS_PATH}/draft`, { map, note: options.note, evidence, based_on: basedOn }, "PUT");
993
+ if (options.format === "json")
994
+ return printJson(saved);
995
+ const based = basedOn === null ? "" : `, based on version ${basedOn}`;
996
+ console.log(saved.applied
997
+ ? `Map version ${saved.version.version} is live${based}: it counts from now. A person can undo it in the app.`
998
+ : `Saved version ${saved.version.version}${based}. It is on hold: it counts nothing until every step can be counted.`);
999
+ if (saved.warnings.length > 0)
1000
+ console.log(`\n${saved.applied ? "Not in synced data yet" : "To fill in"}:\n${saved.warnings.map((w) => `- ${w.path}: ${w.message}`).join("\n")}`);
1001
+ if (saved.weak.length > 0)
1002
+ console.log(`\nNot backed up by synced data:\n${saved.weak.map((w) => `- ${w.path}: ${w.reason}`).join("\n")}`);
1003
+ },
1004
+ },
1005
+ "funnels versions": {
1006
+ usage: "funnels versions [--diff <a> <b>] every version of the map, or what changed from version a to b",
1007
+ async run(args, options) {
1008
+ const connection = await resolveConnection(options.apiUrl);
1009
+ if (options.diff !== undefined) {
1010
+ if (args.length !== 1)
1011
+ throw new CliError("Usage: funnels versions --diff <a> <b>", exitCodes.usage);
1012
+ const pair = [options.diff, args[0]].map((v) => wholeNumber(v, "--diff")).join(",");
1013
+ const diff = await api(connection, `${FUNNELS_PATH}/versions?diff=${pair}`);
1014
+ if (options.format === "json")
1015
+ return printJson(diff);
1016
+ process.stdout.write(diff.text);
1017
+ return;
1018
+ }
1019
+ if (args.length > 0)
1020
+ throw new CliError("Usage: funnels versions [--diff <a> <b>]", exitCodes.usage);
1021
+ const { versions } = await api(connection, `${FUNNELS_PATH}/versions`);
1022
+ if (options.format === "json")
1023
+ return printJson({ versions });
1024
+ if (versions.length === 0)
1025
+ return console.log("No map versions yet.");
1026
+ const rows = versions.map((v) => ({
1027
+ version: v.version,
1028
+ status: v.status,
1029
+ "created by": v.createdBy,
1030
+ "created at": v.createdAt,
1031
+ "confirmed by": v.confirmedBy,
1032
+ "confirmed at": v.confirmedAt,
1033
+ note: v.note && excerpt(v.note),
1034
+ }));
1035
+ console.log(renderTable(["version", "status", "created by", "created at", "confirmed by", "confirmed at", "note"], rows));
1036
+ },
1037
+ },
1038
+ facts: {
1039
+ usage: "facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json] settled facts about this workspace's data, to treat as true",
1040
+ async run(args, options) {
1041
+ if (args.length > 0)
1042
+ throw new CliError("Usage: facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json]", exitCodes.usage);
1043
+ const query = new URLSearchParams();
1044
+ if (options.kind)
1045
+ query.set("kind", options.kind);
1046
+ if (options.subject) {
1047
+ const colon = options.subject.indexOf(":");
1048
+ query.set("subject_type", colon === -1 ? options.subject : options.subject.slice(0, colon));
1049
+ if (colon !== -1 && colon < options.subject.length - 1)
1050
+ query.set("subject_ref", options.subject.slice(colon + 1));
1051
+ }
1052
+ if (options.status)
1053
+ query.set("status", options.status);
1054
+ const { facts } = await api(await resolveConnection(options.apiUrl), `${FACTS_PATH}${query.size ? `?${query}` : ""}`);
1055
+ if (options.format === "json")
1056
+ return printJson({ facts });
1057
+ if (facts.length === 0)
1058
+ return console.log("No facts.");
1059
+ console.log(renderTable(FACT_COLUMNS, facts.map(factRow)));
1060
+ },
1061
+ },
1062
+ "facts show": {
1063
+ usage: "facts show <id> one fact with its value and evidence",
1064
+ async run(args, options) {
1065
+ if (args.length !== 1)
1066
+ throw new CliError("Usage: facts show <id>", exitCodes.usage);
1067
+ const { fact } = await api(await resolveConnection(options.apiUrl), `${FACTS_PATH}/${encodeURIComponent(args[0])}`);
1068
+ if (options.format === "json")
1069
+ return printJson({ fact });
1070
+ printFields({ ...factRow(fact), summary: fact.summary, value: fact.value, evidence: fact.evidence, author: fact.author, "confirmed by": fact.confirmedBy, replaces: fact.replaces });
1071
+ },
1072
+ },
1073
+ "facts propose": {
1074
+ usage: "facts propose --file <fact.json> | < fact.json suggest a fact; a person confirms it in the app",
1075
+ async run(args, options) {
1076
+ if (args.length > 0)
1077
+ throw new CliError("Usage: facts propose --file <fact.json> | < fact.json", exitCodes.usage);
1078
+ const { fact } = await api(await resolveConnection(options.apiUrl), FACTS_PATH, await readToolInput(options));
1079
+ if (options.format === "json")
1080
+ return printJson({ fact });
1081
+ console.log(`Proposed fact ${fact.id}. A person confirms or rejects it in the app.`);
1082
+ },
1083
+ },
921
1084
  "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>]",
1085
+ 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
1086
  async run(args, options) {
924
1087
  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);
1088
+ 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
1089
  }
927
1090
  const { plan } = await api(await resolveConnection(options.apiUrl), "/api/v1/launch-plans", {
928
1091
  name: options.name,
@@ -931,6 +1094,8 @@ const commands = {
931
1094
  ...(options.target === undefined ? {} : { target: await parseJson(options.target, "--target") }),
932
1095
  ...(options.landing === undefined ? {} : { landingItem: options.landing }),
933
1096
  ...(options.landingUrl === undefined ? {} : { landingUrl: options.landingUrl }),
1097
+ ...(options.funnel === undefined ? {} : { funnelKey: options.funnel }),
1098
+ ...(options.step === undefined ? {} : { stepKey: options.step }),
934
1099
  ...(options.run === undefined ? {} : { runId: options.run }),
935
1100
  });
936
1101
  printPlan(plan, options.format);
@@ -1054,9 +1219,9 @@ const commands = {
1054
1219
  },
1055
1220
  },
1056
1221
  "skill install": {
1057
- usage: "skill install [meta-ads|p3-refresh|<name>[@<version>]] [--project] [--codex]",
1222
+ usage: `skill install [${PLAYBOOK_CHOICES}|<name>[@<version>]] [--project] [--codex]`,
1058
1223
  async run(args, options) {
1059
- const usage = "Usage: skill install [meta-ads|p3-refresh|<name>[@<version>]] [--project] [--codex]";
1224
+ const usage = `Usage: skill install [${PLAYBOOK_CHOICES}|<name>[@<version>]] [--project] [--codex]`;
1060
1225
  const [which] = args;
1061
1226
  if (args.length > 1)
1062
1227
  throw new CliError(usage, exitCodes.usage);
@@ -1067,15 +1232,15 @@ const commands = {
1067
1232
  if (which === "meta-ads")
1068
1233
  return installAgentsMd(join(process.cwd(), "AGENTS.md"));
1069
1234
  if (!playbook)
1070
- throw new CliError("--codex installs a playbook: skill install meta-ads|p3-refresh --codex", exitCodes.usage);
1235
+ throw new CliError(`--codex installs a playbook: skill install ${PLAYBOOK_CHOICES} --codex`, exitCodes.usage);
1071
1236
  if (options.project)
1072
1237
  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, []);
1238
+ const path = await writeSkill(codexSkillsDir(), playbook.name, playbook.skill, playbook.files, []);
1074
1239
  return console.log(`Installed the ${playbook.name} skill for Codex to ${path}.`);
1075
1240
  }
1076
- const [name, content] = playbook ? [playbook.name, playbook.skill] : [brand.slug, skill];
1241
+ const [name, content, files] = playbook ? [playbook.name, playbook.skill, playbook.files] : [brand.slug, skill, []];
1077
1242
  const { skillsDir, above } = claudeSkillsDir(options.project);
1078
- const path = await writeSkill(skillsDir, name, content, above);
1243
+ const path = await writeSkill(skillsDir, name, content, files, above);
1079
1244
  console.log(`Installed the ${name} skill to ${path}.`);
1080
1245
  },
1081
1246
  },
@@ -1088,7 +1253,7 @@ const commands = {
1088
1253
  if (options.format === "json")
1089
1254
  return printJson({ workers });
1090
1255
  if (workers.length === 0)
1091
- return console.log("No workers yet.");
1256
+ return console.log("No scheduled hands yet.");
1092
1257
  const rows = workers.map((w) => ({
1093
1258
  id: w.id,
1094
1259
  skill: w.skill,
@@ -1104,10 +1269,10 @@ const commands = {
1104
1269
  },
1105
1270
  },
1106
1271
  "worker runs": {
1107
- usage: "worker runs <worker-id | skill> [--limit <n>]",
1272
+ usage: "worker runs <hand-id | skill> [--limit <n>]",
1108
1273
  async run(args, options) {
1109
1274
  if (args.length !== 1)
1110
- throw new CliError("Usage: worker runs <worker-id | skill> [--limit <n>]", exitCodes.usage);
1275
+ throw new CliError("Usage: worker runs <hand-id | skill> [--limit <n>]", exitCodes.usage);
1111
1276
  const connection = await resolveConnection(options.apiUrl);
1112
1277
  const id = await workerId(connection, args[0]);
1113
1278
  const query = options.limit === undefined ? "" : `?limit=${encodeURIComponent(options.limit)}`;
@@ -1120,10 +1285,10 @@ const commands = {
1120
1285
  },
1121
1286
  },
1122
1287
  "worker run-now": {
1123
- usage: "worker run-now <worker-id | skill>",
1288
+ usage: "worker run-now <hand-id | skill>",
1124
1289
  async run(args, options) {
1125
1290
  if (args.length !== 1)
1126
- throw new CliError("Usage: worker run-now <worker-id | skill>", exitCodes.usage);
1291
+ throw new CliError("Usage: worker run-now <hand-id | skill>", exitCodes.usage);
1127
1292
  const connection = await resolveConnection(options.apiUrl);
1128
1293
  const id = await workerId(connection, args[0]);
1129
1294
  const queued = await api(connection, `/api/v1/workers/${encodeURIComponent(id)}/run-now`, {});
@@ -1140,7 +1305,7 @@ const commands = {
1140
1305
  const { promoted } = await changeCandidate("promote", args, options);
1141
1306
  if (options.format === "json")
1142
1307
  return printJson({ promoted });
1143
- console.log(`${promoted.skill} version ${promoted.version} is now in use on ${promoted.moved} worker${promoted.moved === 1 ? "" : "s"}.`);
1308
+ console.log(`${promoted.skill} version ${promoted.version} is now in use on ${promoted.moved} hand${promoted.moved === 1 ? "" : "s"}.`);
1144
1309
  },
1145
1310
  },
1146
1311
  "worker drop": {
@@ -1175,11 +1340,11 @@ const commands = {
1175
1340
  },
1176
1341
  },
1177
1342
  docs: {
1178
- usage: "docs [meta-ads|p3-refresh]",
1343
+ usage: `docs [${PLAYBOOK_CHOICES}]`,
1179
1344
  async run(args) {
1180
1345
  const [which] = args;
1181
1346
  if (args.length > 1 || (which !== undefined && !isPlaybook(which)))
1182
- throw new CliError("Usage: docs [meta-ads|p3-refresh]", exitCodes.usage);
1347
+ throw new CliError(`Usage: docs [${PLAYBOOK_CHOICES}]`, exitCodes.usage);
1183
1348
  process.stdout.write(which === undefined ? guide : playbooks[which].text);
1184
1349
  },
1185
1350
  },
@@ -1208,10 +1373,6 @@ async function withRunner(options, work) {
1208
1373
  process.off("SIGTERM", onSignal);
1209
1374
  }
1210
1375
  }
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
1376
  const isPlaybook = (name) => Object.hasOwn(playbooks, name);
1216
1377
  const codexSkillsDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "skills");
1217
1378
  /** Claude Code's skills folder, in the user's home or (with --project) the current folder. */
@@ -1232,15 +1393,20 @@ async function installLibrarySkill(ref, options) {
1232
1393
  const path = await installSkillFolder(skillsDir, download.name, files, above);
1233
1394
  console.log(`installed ${download.name} version ${download.version} to ${path}`);
1234
1395
  }
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]);
1396
+ /**
1397
+ * Writes <skillsDir>/<name>/SKILL.md and any files beside it, refusing to follow a symlink at any of `above` or the
1398
+ * skill's own paths. Returns the SKILL.md path.
1399
+ */
1400
+ async function writeSkill(skillsDir, name, content, files, above) {
1401
+ const written = [{ name: "SKILL.md", content }, ...files];
1402
+ const paths = await Promise.all(written.map((f) => inRunFolder(join(skillsDir, name, f.name))));
1403
+ const linked = await firstSymlink([...above, join(skillsDir, name), ...paths]);
1239
1404
  if (linked)
1240
1405
  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;
1406
+ await mkdir(join(skillsDir, name), { recursive: true });
1407
+ for (const [i, file] of written.entries())
1408
+ await writeFileAtomic(paths[i], file.content, 0o644);
1409
+ return paths[0];
1244
1410
  }
1245
1411
  const SOURCE_RUNS_SHOWN = 10;
1246
1412
  /** What the source is retrying: "the whole sync since <time>", and each failing part as "<part> since <time>". */
@@ -1290,6 +1456,11 @@ function printReport(title, report, labels, numbers) {
1290
1456
  console.log(renderTable(["currency", ...numbers], report.totals));
1291
1457
  }
1292
1458
  const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
1459
+ const FACTS_PATH = "/api/v1/facts";
1460
+ const FACT_COLUMNS = ["id", "kind", "subject", "key", "status", "summary"];
1461
+ function factRow(f) {
1462
+ return { id: f.id, kind: f.kind, subject: `${f.subjectType}:${f.subjectRef}`, key: f.key, status: f.status, summary: excerpt(f.summary, 80) };
1463
+ }
1293
1464
  const PROPOSAL_COLUMNS = ["id", "kind", "status", "target", "change", "reason"];
1294
1465
  function proposalChange(p) {
1295
1466
  if (p.kind === "creative")
@@ -1331,7 +1502,7 @@ Options:
1331
1502
  --format table|json output format (default: table)
1332
1503
  --api-url <url> API base URL
1333
1504
  --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
1505
+ --agent <kind> which agent the agent commands use: meta_ads (default) or p3_refresh; in a scheduled hand's run, skill
1335
1506
  (the run's skill's agent) is the default
1336
1507
  --kind <kind> what sends to a new intake address: generic (default), ghl_workflow, aloware, webinarfuel, stealth_seminar or mailgun
1337
1508
  --retention-days <n> how many days intake create keeps each event (default: 90)
@@ -1340,18 +1511,21 @@ Options:
1340
1511
  for warehouse schema, a table to describe in full; repeat for several
1341
1512
  --summary warehouse schema: one short row per table (kind, source, column count, summary)
1342
1513
  --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)
1514
+ --out <path> the file, or existing folder, creatives download and warehouse query save to; warehouse query
1515
+ writes CSV for a .csv path, otherwise JSON
1344
1516
  --open open the file creatives download saved
1345
1517
  --media with content pull, also save each item's files and HTML under media/ in the folder
1346
1518
  --full with content pull, fetch every item, not only what changed since the last pull (brings new numbers)
1347
1519
  --force content unpublish: take the page down even while delivering Meta ads link to it
1520
+ --plan <json> meta adset publish: the ad set to make, {"kind": "copy", ...} or {"kind": "create", ...}
1348
1521
  --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)
1522
+ --from <YYYY-MM-DD> first day funnel, campaigns, money and brief cover (default: 29 days before --to)
1523
+ --to <YYYY-MM-DD> last day funnel, campaigns, money and brief cover (default: today in the workspace reporting time zone)
1351
1524
  --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;
1525
+ --channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
1353
1526
  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
1527
+ --campaign <id> the campaign content track-link puts in utm_campaign; with brief, the one campaign it covers
1528
+ --top <n> how many best ads, worst ads, pages and campaigns brief shows (default: 5, at most 20)
1355
1529
  --body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
1356
1530
  --file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several
1357
1531
  --transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
@@ -1362,6 +1536,14 @@ Options:
1362
1536
  --run <id> the agent run making the content (content), or the tool run to record (creatives create)
1363
1537
  --platform, --external-id where content status launched says it was published, and that platform's id for it
1364
1538
  --notes <text> what changed in the version skill publish adds
1539
+ --version <n> the map version funnels show prints (default: the confirmed one)
1540
+ --based-on <n> the map version funnels draft was written from; refused when someone saved since
1541
+ (leave it out only for the first map, when there is neither a map nor a draft)
1542
+ --evidence <json | @file.json> what funnels draft was written from: counts and ids, never names, emails or phones
1543
+ --diff <a> <b> with funnels versions, what changed from version a to version b
1544
+ --funnel <key> only this funnel's steps in funnels numbers (default: every funnel in the confirmed map);
1545
+ with plans create and --step, the funnel whose step the push feeds
1546
+ --step <key> with plans create, the step of --funnel the push feeds, as on the confirmed map
1365
1547
  --harness <list> which agents skill publish marks the skill for: claude, codex, or claude,codex (default)
1366
1548
  --text <words> words content search finds in a subject, headline, body or video transcript
1367
1549
  --mode word|meaning how content search matches --text: word (default, the words themselves) or meaning (close in meaning, nearest first; no --sort)
@@ -1372,13 +1554,13 @@ Options:
1372
1554
  --order desc|asc content search's sort direction (default: desc, best or newest first)
1373
1555
  --limit <n> how many results content search, creatives list and campaigns return
1374
1556
  --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
1557
+ --workspace <slug> for worker promote and worker drop: the workspace whose hand they change
1376
1558
  -h, --help show this help
1377
1559
 
1378
1560
  skill publish, skill list and skill install <name> use the skill library, which takes the operator token in
1379
1561
  ${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.
1562
+ version a scheduled hand is trying the one in use, there and on every hand of the skill on an older version; worker
1563
+ drop stops trying it.
1382
1564
 
1383
1565
  runner and runner once take the runner token in ${RUNNER_TOKEN_ENV}. ${HARNESS_COMMAND_ENV}, if set, names a program
1384
1566
  the runner starts instead of Claude Code or Codex.
@@ -1391,6 +1573,7 @@ function parseOptions(argv) {
1391
1573
  allowPositionals: true,
1392
1574
  options: {
1393
1575
  format: { type: "string", default: "table" },
1576
+ json: { type: "boolean", default: false },
1394
1577
  "api-url": { type: "string" },
1395
1578
  file: { type: "string", multiple: true },
1396
1579
  project: { type: "boolean", default: false },
@@ -1421,6 +1604,7 @@ function parseOptions(argv) {
1421
1604
  grain: { type: "string" },
1422
1605
  channel: { type: "string" },
1423
1606
  campaign: { type: "string" },
1607
+ top: { type: "string" },
1424
1608
  name: { type: "string" },
1425
1609
  subject: { type: "string" },
1426
1610
  preview: { type: "string" },
@@ -1451,6 +1635,11 @@ function parseOptions(argv) {
1451
1635
  position: { type: "string" },
1452
1636
  plan: { type: "string" },
1453
1637
  version: { type: "string" },
1638
+ "based-on": { type: "string" },
1639
+ evidence: { type: "string" },
1640
+ diff: { type: "string" },
1641
+ funnel: { type: "string" },
1642
+ step: { type: "string" },
1454
1643
  workspace: { type: "string" },
1455
1644
  "max-seconds": { type: "string" },
1456
1645
  help: { type: "boolean", short: "h", default: false },
@@ -1470,7 +1659,7 @@ function parseOptions(argv) {
1470
1659
  positionals,
1471
1660
  help: values.help,
1472
1661
  options: {
1473
- format: values.format,
1662
+ format: values.json ? "json" : values.format,
1474
1663
  apiUrl: values["api-url"],
1475
1664
  file: values.file?.at(-1),
1476
1665
  files: values.file ?? [],
@@ -1503,6 +1692,7 @@ function parseOptions(argv) {
1503
1692
  grain: values.grain,
1504
1693
  channel: values.channel,
1505
1694
  campaign: values.campaign,
1695
+ top: values.top,
1506
1696
  name: values.name,
1507
1697
  subject: values.subject,
1508
1698
  preview: values.preview,
@@ -1533,18 +1723,26 @@ function parseOptions(argv) {
1533
1723
  position: values.position,
1534
1724
  plan: values.plan,
1535
1725
  version: values.version,
1726
+ basedOn: values["based-on"],
1727
+ evidence: values.evidence,
1728
+ diff: values.diff,
1729
+ funnel: values.funnel,
1730
+ step: values.step,
1536
1731
  workspace: values.workspace,
1537
1732
  maxSeconds,
1538
1733
  },
1539
1734
  };
1540
1735
  }
1541
- /** Promotes or drops the candidate version of a workspace's worker for a skill, with the operator token. */
1736
+ /** Promotes or drops the candidate version of a workspace's scheduled hand for a skill, with the operator token. */
1542
1737
  async function changeCandidate(change, args, options) {
1543
1738
  if (args.length !== 1 || !options.workspace)
1544
1739
  throw new CliError(`Usage: worker ${change} <skill> --workspace <slug>`, exitCodes.usage);
1545
1740
  return api(await operatorConnection(options.apiUrl), `/api/operator/workers/${change}`, { workspace: options.workspace, skill: args[0] });
1546
1741
  }
1547
1742
  function findCommand(positionals) {
1743
+ const three = positionals.slice(0, 3).join(" ");
1744
+ if (Object.hasOwn(commands, three))
1745
+ return { command: commands[three], args: positionals.slice(3) };
1548
1746
  const two = positionals.slice(0, 2).join(" ");
1549
1747
  if (Object.hasOwn(commands, two))
1550
1748
  return { command: commands[two], args: positionals.slice(2) };
@@ -1740,6 +1938,14 @@ async function uploadContentFile(connection, data, contentType) {
1740
1938
  await putSigned(upload.url, data, contentType);
1741
1939
  return upload.uploadId;
1742
1940
  }
1941
+ /** What the CLI says after a link: that its platform no longer lists it, or that it was edited there. */
1942
+ function linkNote(link) {
1943
+ if (link.removedAt)
1944
+ return ` (no longer on ${link.platform})`;
1945
+ if (link.platformChangedAt)
1946
+ return ` (changed on ${link.platform} since it was published)`;
1947
+ return "";
1948
+ }
1743
1949
  function printContent(content, format) {
1744
1950
  if (format === "json")
1745
1951
  return printJson(content);
@@ -1758,7 +1964,7 @@ function printContent(content, format) {
1758
1964
  html: version.htmlUrl,
1759
1965
  files: version.media.length ? version.media.map((m) => m.url).join(" ") : null,
1760
1966
  links: item.links.length
1761
- ? item.links.map((l) => `${l.platform} ${l.objectType} ${l.externalId}${l.removedAt ? ` (no longer on ${l.platform})` : ""}`).join(", ")
1967
+ ? item.links.map((l) => `${l.platform} ${l.objectType} ${l.externalId}${linkNote(l)}`).join(", ")
1762
1968
  : null,
1763
1969
  by: version.capturedBy,
1764
1970
  note: version.note,
@@ -1769,10 +1975,20 @@ function printContent(content, format) {
1769
1975
  deployments: content.deployments?.length ? content.deployments.map(deploymentLine).join(", ") : null,
1770
1976
  versions: content.versions && content.versions.length > 1 ? content.versions.length : null,
1771
1977
  transcript: content.transcripts?.map((t) => (t.text ? excerpt(t.text, 80) : t.status)).join(" | ") || null,
1978
+ description: descriptionLine(content.descriptions?.find((d) => d.version === version.version)),
1772
1979
  ...scoreFields(content.scorecards ?? []),
1773
1980
  };
1774
1981
  printFields(rows);
1775
1982
  }
1983
+ /** The current version's picture description in one line: its kind, and the words printed on it. */
1984
+ function descriptionLine(d) {
1985
+ if (!d)
1986
+ return null;
1987
+ if (!d.description)
1988
+ return d.status;
1989
+ const words = d.description.on_image_text.trim();
1990
+ return words ? `${d.description.visual_kind}: "${excerpt(words, 80)}"` : d.description.visual_kind;
1991
+ }
1776
1992
  function printContentDiff(diff) {
1777
1993
  const stamp = (sign, v) => `${sign} version ${v.version}, ${v.capturedAt} by ${v.capturedBy}${v.note ? `: ${v.note}` : ""}`;
1778
1994
  const value = (v) => (typeof v === "string" ? v : JSON.stringify(v));
@@ -1794,6 +2010,10 @@ function printContentDiff(diff) {
1794
2010
  lines.push("", "No changes.");
1795
2011
  console.log(lines.join("\n"));
1796
2012
  }
2013
+ function feedsText(funnel) {
2014
+ const sells = funnel.sellsOffer === null ? "" : `, which sells ${funnel.sellsOffer.name}`;
2015
+ return `step ${funnel.step.position} (${funnel.step.name}) of ${funnel.name}${sells}`;
2016
+ }
1797
2017
  const planPath = (id) => `/api/v1/launch-plans/${encodeURIComponent(id)}`;
1798
2018
  function printPlan(plan, format) {
1799
2019
  if (format === "json")
@@ -1808,6 +2028,9 @@ function printPlan(plan, format) {
1808
2028
  status: plan.status,
1809
2029
  next: plan.moves.length ? plan.moves.join(", ") : null,
1810
2030
  by: plan.createdBy,
2031
+ feeds: plan.funnel && feedsText(plan.funnel),
2032
+ "funnel note": plan.funnelNote,
2033
+ "funnel steps": plan.funnel?.steps.map((s) => `${s.position}. ${s.name}`).join(", "),
1811
2034
  });
1812
2035
  console.log("");
1813
2036
  if (plan.items.length === 0)
@@ -1816,12 +2039,12 @@ function printPlan(plan, format) {
1816
2039
  }
1817
2040
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1818
2041
  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. */
2042
+ /** 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
2043
  async function workerId(connection, ref) {
1821
2044
  const { workers } = await api(connection, "/api/v1/workers");
1822
2045
  const found = workers.find((w) => w.skill === ref) ?? workers.find((w) => w.id === ref.toLowerCase());
1823
2046
  if (!found)
1824
- throw new CliError(`unknown_worker: no worker with skill or id ${JSON.stringify(ref)} in this workspace`, exitCodes.error);
2047
+ throw new CliError(`unknown_worker: no scheduled hand with skill or id ${JSON.stringify(ref)} in this workspace`, exitCodes.error);
1825
2048
  return found.id;
1826
2049
  }
1827
2050
  function workerRunRow(r) {
@@ -1921,6 +2144,27 @@ function printPublication(publication, format) {
1921
2144
  finished: publication.finishedAt,
1922
2145
  });
1923
2146
  }
2147
+ function printAdSetPublication(publication, format) {
2148
+ if (format === "json")
2149
+ return printJson({ adsetPublication: publication });
2150
+ const { verification, budget } = publication;
2151
+ printFields({
2152
+ "adset publication": publication.id,
2153
+ kind: publication.kind,
2154
+ account: publication.accountId,
2155
+ campaign: publication.campaignId,
2156
+ source: publication.sourceAdsetId,
2157
+ name: publication.name,
2158
+ "external id": publication.externalId,
2159
+ status: publication.status,
2160
+ error: publication.error,
2161
+ verified: verification && (verification.ok ? "yes" : `no: ${verification.problems.join("; ")}`),
2162
+ budget: budget && (budget.dailyBudget !== null ? `${budget.dailyBudget} a day` : budget.lifetimeBudget !== null ? `${budget.lifetimeBudget} lifetime` : "the campaign's"),
2163
+ recorded: publication.recordedAt,
2164
+ sent: publication.sentAt,
2165
+ finished: publication.finishedAt,
2166
+ });
2167
+ }
1924
2168
  async function readToolInput(options) {
1925
2169
  let text;
1926
2170
  if (options.input !== undefined)
@@ -2047,6 +2291,53 @@ function decodeLinkPath(encoded) {
2047
2291
  throw new CliError(`"${excerpt(encoded)}" is not a valid asset link.`, exitCodes.usage);
2048
2292
  }
2049
2293
  }
2294
+ async function saveFile(path, content) {
2295
+ await writeFileAtomic(path, content, 0o644).catch((error) => {
2296
+ if (error.code === "ENOENT")
2297
+ throw new CliError(`No folder at ${dirname(path)} to save into.`, exitCodes.usage);
2298
+ throw error;
2299
+ });
2300
+ }
2301
+ /** Saves every row the server returned: CSV for a `.csv` path, otherwise the response body as it came. */
2302
+ async function saveQueryResult(text, out, format) {
2303
+ const path = await savePath(out, "results.json");
2304
+ const result = queryResultWithExactNumbers(text);
2305
+ await saveFile(path, extname(path).toLowerCase() === ".csv" ? toCsv(text) : text);
2306
+ if (format === "json")
2307
+ printJson({ path: resolve(path), rowCount: result.rows.length, truncated: result.truncated });
2308
+ else
2309
+ console.log(`Saved ${rowCount(result.rows.length)} to ${path}`);
2310
+ warnIfTruncated(result);
2311
+ }
2312
+ /** Top-level numbers keep the digits the server sent, so a numeric column is never rounded. */
2313
+ function queryResultWithExactNumbers(text) {
2314
+ const result = JSON.parse(text);
2315
+ const exact = JSON.parse(text, keepNumberDigits);
2316
+ const rows = result.rows.map((row, i) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, typeof v === "number" ? exact.rows[i][k] : v])));
2317
+ return { ...result, rows };
2318
+ }
2319
+ function rowCount(n) {
2320
+ return `${n.toLocaleString("en-US")} ${n === 1 ? "row" : "rows"}`;
2321
+ }
2322
+ function warnIfTruncated(result) {
2323
+ if (result.truncated)
2324
+ console.error(`Results were cut off (${result.truncated}). Aggregate in SQL or add LIMIT.`);
2325
+ }
2326
+ /** Every number keeps the digits the server sent, nested ones too, and json columns are written as JSON text. */
2327
+ function toCsv(text) {
2328
+ const result = JSON.parse(text, keepRawNumbers);
2329
+ const json = new Set(result.columns.filter((c) => c.type === "json" || c.type === "jsonb").map((c) => c.name));
2330
+ const field = (name, value) => (value != null && json.has(name) ? JSON.stringify(value) : cell(value));
2331
+ const names = result.columns.map((c) => c.name);
2332
+ const line = (values) => values.map(csvField).join(",");
2333
+ return [line(names), ...result.rows.map((row) => line(names.map((name) => field(name, row[name]))))].map((l) => `${l}\n`).join("");
2334
+ }
2335
+ function keepRawNumbers(_key, value, context) {
2336
+ return typeof value === "number" && context?.source !== undefined ? JSON.rawJSON(context.source) : value;
2337
+ }
2338
+ function csvField(value) {
2339
+ return /[",\r\n]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
2340
+ }
2050
2341
  /** `--out` names the file, or an existing folder to save into under the default name. */
2051
2342
  async function savePath(out, fileName) {
2052
2343
  if (out === undefined)
@@ -2074,6 +2365,13 @@ function openFile(path) {
2074
2365
  child.unref();
2075
2366
  }
2076
2367
  const INTAKE_PATH = "/api/v1/intake-endpoints";
2368
+ const FUNNELS_PATH = "/api/v1/funnels";
2369
+ function wholeNumber(value, flag) {
2370
+ const n = Number(value);
2371
+ if (!Number.isInteger(n) || n < 1)
2372
+ throw new CliError(`${flag} must be a whole number from 1.`, exitCodes.usage);
2373
+ return n;
2374
+ }
2077
2375
  const intakePath = (id) => `${INTAKE_PATH}/${encodeURIComponent(id)}`;
2078
2376
  function parseRetentionDays(text) {
2079
2377
  const days = Number(text);