@wrangled/cli 0.3.43 → 0.3.45

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
@@ -4,7 +4,7 @@ import { mkdir, readFile, stat } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { basename, dirname, extname, join, resolve } from "node:path";
6
6
  import { parseArgs } from "node:util";
7
- import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, IMPORT_NAME_PATTERN, putSigned, readCredentials, removeCredentials, resolveApiUrl, resolveConnection, UnreadableCredentialsError, writeCredentials, entryTagFlags, streamSigned, } from "./api.js";
7
+ import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, IMPORT_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, entryTagFlags, streamSigned, } from "./api.js";
8
8
  import { brand, envPrefix } from "./brand.js";
9
9
  import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
10
10
  import { guide, skill } from "./guide.js";
@@ -19,41 +19,47 @@ const SERVER_WAIT_CAP = 50;
19
19
  const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
20
20
  const commands = {
21
21
  login: {
22
- usage: `login (paste the key when asked, pipe it on stdin, or set ${envPrefix}_API_KEY)`,
22
+ usage: `login [--client <name>] (paste the key when asked, pipe it on stdin, or set ${envPrefix}_API_KEY); --client saves it under that client's name`,
23
23
  async run(args, options) {
24
24
  if (args.length > 0) {
25
25
  throw new CliError(`login takes no arguments. Pipe the key on stdin or set ${envPrefix}_API_KEY.`, exitCodes.usage);
26
26
  }
27
+ const client = options.client === undefined ? undefined : parseClientName(options.client);
27
28
  const apiKey = await readKeyFromUser();
28
- const stored = await readCredentials().catch((error) => {
29
+ const saved = await readSavedLogins().catch((error) => {
29
30
  if (error instanceof UnreadableCredentialsError)
30
31
  return null;
31
32
  throw error;
32
33
  });
33
- const apiUrl = resolveApiUrl(options.apiUrl, stored);
34
+ const apiUrl = resolveApiUrl(options.apiUrl, (client === undefined ? undefined : saved?.clients[client]) ?? saved?.default ?? null);
34
35
  const me = await api({ apiUrl, apiKey }, "/api/v1/me");
35
- const path = await writeCredentials({ apiKey, apiUrl });
36
+ const path = await writeCredentials({ apiKey, apiUrl }, { client });
36
37
  if (options.format === "json")
37
38
  return printJson(me);
38
- console.log(`Logged in to ${me.workspace.name} (${me.workspace.slug}) at ${apiUrl}.`);
39
+ console.log(`Logged in to ${me.workspace.name} (${me.workspace.slug}) at ${apiUrl}${client === undefined ? "" : ` as client ${client}`}.`);
39
40
  console.log(`Saved credentials to ${path}.`);
40
41
  },
41
42
  },
42
43
  logout: {
43
- usage: "logout",
44
- async run() {
45
- await removeCredentials();
46
- console.log("Logged out.");
44
+ usage: "logout [--client <name>] (without --client, removes the default login and keeps named clients)",
45
+ async run(_args, options) {
46
+ const client = options.client === undefined ? undefined : parseClientName(options.client);
47
+ await removeCredentials(client);
48
+ console.log(client === undefined ? "Logged out." : `Logged out of client ${client}.`);
47
49
  },
48
50
  },
49
51
  whoami: {
50
- usage: "whoami",
52
+ usage: "whoami (also says which client's key is in use and why)",
51
53
  async run(_args, options) {
52
- const me = await api(await resolveConnection(options.apiUrl), "/api/v1/me");
54
+ const { connection, choice } = await resolveLogin(options);
55
+ const me = await api(connection, "/api/v1/me");
56
+ const client = "client" in choice ? choice.client : null;
57
+ const clientFile = choice.source === "folder" ? choice.path : null;
53
58
  if (options.format === "json")
54
- return printJson(me);
59
+ return printJson({ ...me, client, source: choice.source, clientFile });
55
60
  const { name, slug, id } = me.workspace;
56
61
  console.log(renderTable(["workspace", "slug", "id", "library access"], [{ workspace: name, slug, id, "library access": me.knowledge ? "on" : "off" }]));
62
+ console.log(`\n${clientReason(choice)}`);
57
63
  },
58
64
  },
59
65
  "knowledge search": {
@@ -72,7 +78,7 @@ const commands = {
72
78
  query.set("library", options.library);
73
79
  for (const [field, value] of filters)
74
80
  query.set(field, value);
75
- const answer = await api(await resolveConnection(options.apiUrl), `/api/v1/knowledge/search?${query}`);
81
+ const answer = await api(await resolveConnection(options), `/api/v1/knowledge/search?${query}`);
76
82
  if (options.format === "json")
77
83
  return printJson(answer);
78
84
  console.log(answer.results.length === 0 ? "Nothing in the library matched." : answer.results.map(knowledgeBlock).join("\n\n"));
@@ -102,7 +108,7 @@ const commands = {
102
108
  })),
103
109
  }
104
110
  : await readToolInput(options);
105
- const result = await api(await resolveConnection(options.apiUrl), KNOWLEDGE_TAGS_PATH, body);
111
+ const result = await api(await resolveConnection(options), KNOWLEDGE_TAGS_PATH, body);
106
112
  if (options.format === "json")
107
113
  return printJson(result);
108
114
  console.log(`Tagged ${result.tagged}, took off ${result.removed}.`);
@@ -121,7 +127,7 @@ const commands = {
121
127
  if (options.search !== undefined)
122
128
  query.set("search", options.search);
123
129
  const path = `/api/v1/warehouse/schema${query.size > 0 ? `?${query}` : ""}`;
124
- const connection = await resolveConnection(options.apiUrl);
130
+ const connection = await resolveConnection(options);
125
131
  if (options.summary) {
126
132
  const summary = await api(connection, path);
127
133
  if (options.format === "json")
@@ -162,7 +168,7 @@ const commands = {
162
168
  throw new CliError("--all saves to a file: add --out <file.csv | file.ndjson | file.json>.", exitCodes.usage);
163
169
  }
164
170
  const sql = await readSql(args, options.file);
165
- const connection = await resolveConnection(options.apiUrl);
171
+ const connection = await resolveConnection(options);
166
172
  if (options.all)
167
173
  return saveWholeAnswer(connection, sql, options.out, options.format);
168
174
  const text = await apiText(connection, "/api/v1/warehouse/query", { sql });
@@ -182,7 +188,7 @@ const commands = {
182
188
  if (args.length > 0)
183
189
  throw new CliError("Usage: funnel [--from <date>] [--to <date>] [--grain day|week|month]", exitCodes.usage);
184
190
  const query = reportQuery(options, { grain: options.grain });
185
- const { funnel } = await api(await resolveConnection(options.apiUrl), `/api/v1/reports/funnel${query}`);
191
+ const { funnel } = await api(await resolveConnection(options), `/api/v1/reports/funnel${query}`);
186
192
  if (options.format === "json")
187
193
  return printJson({ funnel });
188
194
  printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], FUNNEL_NUMBERS);
@@ -195,7 +201,7 @@ const commands = {
195
201
  if (args.length > 0)
196
202
  throw new CliError("Usage: money [--from <date>] [--to <date>] [--grain day|week|month]", exitCodes.usage);
197
203
  const query = reportQuery(options, { grain: options.grain });
198
- const { money } = await api(await resolveConnection(options.apiUrl), `/api/v1/reports/money${query}`);
204
+ const { money } = await api(await resolveConnection(options), `/api/v1/reports/money${query}`);
199
205
  if (options.format === "json")
200
206
  return printJson({ money });
201
207
  printReport(`Money by ${money.grain}`, money, ["period", "currency"], MONEY_NUMBERS);
@@ -218,7 +224,7 @@ const commands = {
218
224
  if (args.length > 0)
219
225
  throw new CliError("Usage: campaigns [--from <date>] [--to <date>] [--channel <channel>] [--limit <n>]", exitCodes.usage);
220
226
  const query = reportQuery(options, { channel: options.channel, limit: options.limit });
221
- const { campaignResults } = await api(await resolveConnection(options.apiUrl), `/api/v1/reports/campaigns${query}`);
227
+ const { campaignResults } = await api(await resolveConnection(options), `/api/v1/reports/campaigns${query}`);
222
228
  if (options.format === "json")
223
229
  return printJson({ campaignResults });
224
230
  const title = campaignResults.channel === null ? "Campaigns, best first" : `${campaignResults.channel} campaigns, best first`;
@@ -229,7 +235,7 @@ const commands = {
229
235
  "tools list": {
230
236
  usage: "tools list",
231
237
  async run(_args, options) {
232
- const { tools } = await api(await resolveConnection(options.apiUrl), "/api/v1/tools");
238
+ const { tools } = await api(await resolveConnection(options), "/api/v1/tools");
233
239
  if (options.format === "json")
234
240
  return printJson({ tools });
235
241
  console.log(renderTable(["id", "mode", "output", "title"], tools));
@@ -242,7 +248,7 @@ const commands = {
242
248
  if (!tool || rest.length > 0)
243
249
  throw new CliError("Usage: tools run <tool> [--input '<json>']", exitCodes.usage);
244
250
  const input = await readToolInput(options);
245
- const connection = await resolveConnection(options.apiUrl);
251
+ const connection = await resolveConnection(options);
246
252
  const body = {
247
253
  input,
248
254
  ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
@@ -257,7 +263,7 @@ const commands = {
257
263
  async run(args, options) {
258
264
  if (args.length !== 1)
259
265
  throw new CliError("Usage: tools get <run-id>", exitCodes.usage);
260
- const connection = await resolveConnection(options.apiUrl);
266
+ const connection = await resolveConnection(options);
261
267
  const { run } = await api(connection, `/api/v1/runs/${encodeURIComponent(args[0])}`);
262
268
  printRun(await waitForRun(connection, run, options.wait), options.format);
263
269
  },
@@ -267,7 +273,7 @@ const commands = {
267
273
  async run(args, options) {
268
274
  if (args.length > 0)
269
275
  throw new CliError("Usage: sources list", exitCodes.usage);
270
- const { sources } = await api(await resolveConnection(options.apiUrl), "/api/v1/sources");
276
+ const { sources } = await api(await resolveConnection(options), "/api/v1/sources");
271
277
  if (options.format === "json")
272
278
  return printJson({ sources });
273
279
  if (sources.length === 0)
@@ -296,7 +302,7 @@ const commands = {
296
302
  async run(args, options) {
297
303
  if (args.length !== 1)
298
304
  throw new CliError("Usage: sources show <id>", exitCodes.usage);
299
- const connection = await resolveConnection(options.apiUrl);
305
+ const connection = await resolveConnection(options);
300
306
  const path = `/api/v1/sources/${encodeURIComponent(args[0])}`;
301
307
  const { source } = await api(connection, path);
302
308
  const { runs } = await api(connection, `${path}/runs?limit=${SOURCE_RUNS_SHOWN}`);
@@ -340,7 +346,7 @@ const commands = {
340
346
  const [id, ...assignments] = args;
341
347
  if (!id)
342
348
  throw new CliError("Usage: sources settings <id> [<key>=<value> ...]", exitCodes.usage);
343
- const connection = await resolveConnection(options.apiUrl);
349
+ const connection = await resolveConnection(options);
344
350
  const path = `/api/v1/sources/${encodeURIComponent(id)}/settings`;
345
351
  let { settings } = await api(connection, path);
346
352
  if (assignments.length > 0) {
@@ -366,7 +372,7 @@ const commands = {
366
372
  async run(args, options) {
367
373
  if (args.length > 0)
368
374
  throw new CliError("Usage: intake list", exitCodes.usage);
369
- const { endpoints } = await api(await resolveConnection(options.apiUrl), INTAKE_PATH);
375
+ const { endpoints } = await api(await resolveConnection(options), INTAKE_PATH);
370
376
  if (options.format === "json")
371
377
  return printJson({ endpoints });
372
378
  if (endpoints.length === 0)
@@ -396,7 +402,7 @@ const commands = {
396
402
  ...(retentionDays === undefined ? {} : { retentionDays }),
397
403
  ...(signingKey === undefined ? {} : { signingKey }),
398
404
  };
399
- printIntakeToken(await api(await resolveConnection(options.apiUrl), INTAKE_PATH, body), options.format);
405
+ printIntakeToken(await api(await resolveConnection(options), INTAKE_PATH, body), options.format);
400
406
  },
401
407
  },
402
408
  "intake rotate": {
@@ -405,7 +411,7 @@ const commands = {
405
411
  if (args.length !== 1)
406
412
  throw new CliError("Usage: intake rotate <id>", exitCodes.usage);
407
413
  const path = `${intakePath(args[0])}/rotate`;
408
- printIntakeToken(await api(await resolveConnection(options.apiUrl), path, {}), options.format);
414
+ printIntakeToken(await api(await resolveConnection(options), path, {}), options.format);
409
415
  },
410
416
  },
411
417
  "intake signing-key": {
@@ -415,7 +421,7 @@ const commands = {
415
421
  throw new CliError("Usage: intake signing-key <id> --signing-key-stdin", exitCodes.usage);
416
422
  const signingKey = await readSigningKey();
417
423
  const path = `${intakePath(args[0])}/signing-key`;
418
- const { endpoint } = await api(await resolveConnection(options.apiUrl), path, { signingKey }, "PUT");
424
+ const { endpoint } = await api(await resolveConnection(options), path, { signingKey }, "PUT");
419
425
  if (options.format === "json")
420
426
  return printJson({ endpoint });
421
427
  console.log(`Replaced the ${endpoint.kind} signing key. Every ${endpoint.kind} address in the workspace now checks against it.`);
@@ -462,7 +468,7 @@ const commands = {
462
468
  if (!options.yes) {
463
469
  throw new CliError("This deletes the address and every event it has saved. Add --yes to confirm: intake delete <id> --yes", exitCodes.usage);
464
470
  }
465
- const result = await api(await resolveConnection(options.apiUrl), intakePath(args[0]), undefined, "DELETE");
471
+ const result = await api(await resolveConnection(options), intakePath(args[0]), undefined, "DELETE");
466
472
  if (options.format === "json")
467
473
  return printJson(result);
468
474
  console.log("Deleted the address and its saved events.");
@@ -476,7 +482,7 @@ const commands = {
476
482
  throw new CliError(usage, exitCodes.usage);
477
483
  const name = importName(options.table);
478
484
  const file = await readImportFile(args[0]);
479
- const connection = await resolveConnection(options.apiUrl);
485
+ const connection = await resolveConnection(options);
480
486
  const result = await uploadCsv(connection, name, file);
481
487
  if (options.format === "json")
482
488
  return printJson(result);
@@ -495,7 +501,7 @@ const commands = {
495
501
  throw new CliError(`This drops the table import_${name} and all its rows. Add --yes to confirm: import remove --table ${name} --yes`, exitCodes.usage);
496
502
  }
497
503
  const path = `/api/v1/imports/csv?table=${encodeURIComponent(name)}`;
498
- const result = await api(await resolveConnection(options.apiUrl), path, undefined, "DELETE");
504
+ const result = await api(await resolveConnection(options), path, undefined, "DELETE");
499
505
  if (options.format === "json")
500
506
  return printJson(result);
501
507
  console.log(`Removed ${result.table} and its rows.`);
@@ -512,7 +518,7 @@ const commands = {
512
518
  if (options.limit)
513
519
  query.set("limit", options.limit);
514
520
  const path = `/api/v1/creatives${query.size ? `?${query}` : ""}`;
515
- const { creatives } = await api(await resolveConnection(options.apiUrl), path);
521
+ const { creatives } = await api(await resolveConnection(options), path);
516
522
  if (options.format === "json")
517
523
  return printJson({ creatives });
518
524
  if (creatives.length === 0)
@@ -526,7 +532,7 @@ const commands = {
526
532
  async run(args, options) {
527
533
  if (args.length !== 1)
528
534
  throw new CliError("Usage: creatives show <sku>", exitCodes.usage);
529
- const { creative } = await api(await resolveConnection(options.apiUrl), `/api/v1/creatives/${encodeURIComponent(args[0])}`);
535
+ const { creative } = await api(await resolveConnection(options), `/api/v1/creatives/${encodeURIComponent(args[0])}`);
530
536
  if (options.format === "json")
531
537
  return printJson({ creative });
532
538
  const fields = {
@@ -543,7 +549,7 @@ const commands = {
543
549
  async run(args, options) {
544
550
  if (args.length !== 1)
545
551
  throw new CliError("Usage: creatives download <sku | asset-path | asset-link> [--out <path>] [--open]", exitCodes.usage);
546
- const connection = await resolveConnection(options.apiUrl);
552
+ const connection = await resolveConnection(options);
547
553
  const target = await downloadTarget(args[0], connection);
548
554
  const { bytes, contentType } = await apiDownload(connection, `${ASSET_ROUTE}${target.assetPath.split("/").map(encodeURIComponent).join("/")}`);
549
555
  const path = await savePath(options.out, extname(target.fileName) ? target.fileName : `${target.fileName}.${extensionFor(contentType)}`);
@@ -563,7 +569,7 @@ const commands = {
563
569
  }
564
570
  const lineage = options.lineage === undefined ? undefined : await parseJson(options.lineage, "--lineage");
565
571
  const tags = options.tags === undefined ? undefined : await parseJson(options.tags, "--tags");
566
- const { creatives } = await api(await resolveConnection(options.apiUrl), "/api/v1/creatives", {
572
+ const { creatives } = await api(await resolveConnection(options), "/api/v1/creatives", {
567
573
  runId: options.run,
568
574
  ...(lineage === undefined ? {} : { lineage }),
569
575
  ...(tags === undefined ? {} : { tags }),
@@ -579,7 +585,7 @@ const commands = {
579
585
  if (args.length !== 1)
580
586
  throw new CliError("Usage: creatives tag <sku> --file <tags.json> | < tags.json", exitCodes.usage);
581
587
  const tags = await readToolInput(options);
582
- const { creative } = await api(await resolveConnection(options.apiUrl), `/api/v1/creatives/${encodeURIComponent(args[0])}/tags`, { tags }, "PATCH");
588
+ const { creative } = await api(await resolveConnection(options), `/api/v1/creatives/${encodeURIComponent(args[0])}/tags`, { tags }, "PATCH");
583
589
  if (options.format === "json")
584
590
  return printJson({ creative });
585
591
  console.log(`Tagged ${creative.sku}. The tags read ${creative.tagsStatus} until a person confirms them in the app.`);
@@ -590,7 +596,7 @@ const commands = {
590
596
  async run(args, options) {
591
597
  if (args.length > 0 || !options.kind)
592
598
  throw new CliError("Usage: content create --kind ad|email|sms|landing_page|video|social_post [...]", exitCodes.usage);
593
- const connection = await resolveConnection(options.apiUrl);
599
+ const connection = await resolveConnection(options);
594
600
  const content = await api(connection, "/api/v1/content", {
595
601
  kind: options.kind,
596
602
  ...(await contentInput(options, connection)),
@@ -608,7 +614,7 @@ const commands = {
608
614
  }
609
615
  if (options.language !== undefined && options.transcript === undefined)
610
616
  throw new CliError("--language goes with --transcript.", exitCodes.usage);
611
- const connection = await resolveConnection(options.apiUrl);
617
+ const connection = await resolveConnection(options);
612
618
  const content = await api(connection, "/api/v1/content/captures", {
613
619
  kind: options.kind,
614
620
  ...(await contentInput(options, connection)),
@@ -624,7 +630,7 @@ const commands = {
624
630
  async run(args, options) {
625
631
  if (args.length > 0)
626
632
  throw new CliError("Usage: guide", exitCodes.usage);
627
- const guide = await api(await resolveConnection(options.apiUrl), "/api/v1/guide");
633
+ const guide = await api(await resolveConnection(options), "/api/v1/guide");
628
634
  if (options.format === "json")
629
635
  return printJson(guide);
630
636
  process.stdout.write(guide.text);
@@ -636,7 +642,7 @@ const commands = {
636
642
  if (args.length > 0)
637
643
  throw new CliError("Usage: brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id>] [--top <n>]", exitCodes.usage);
638
644
  const query = reportQuery(options, { channel: options.channel, campaignId: options.campaign, top: options.top });
639
- const brief = await api(await resolveConnection(options.apiUrl), `/api/v1/brief${query}`);
645
+ const brief = await api(await resolveConnection(options), `/api/v1/brief${query}`);
640
646
  if (options.format === "json")
641
647
  return printJson(brief);
642
648
  process.stdout.write(brief.text);
@@ -652,7 +658,7 @@ const commands = {
652
658
  for (const [name, value] of Object.entries({ step: options.step, funnel: options.funnel, page: options.page }))
653
659
  if (value !== undefined)
654
660
  query.set(name, value);
655
- const brief = await api(await resolveConnection(options.apiUrl), `/api/v1/pages/brief?${query}`);
661
+ const brief = await api(await resolveConnection(options), `/api/v1/pages/brief?${query}`);
656
662
  if (options.format === "json")
657
663
  return printJson(brief);
658
664
  process.stdout.write(brief.text);
@@ -663,7 +669,7 @@ const commands = {
663
669
  async run(args, options) {
664
670
  if (args.length > 0)
665
671
  throw new CliError("Usage: pages results [--from <date>] [--to <date>] [--page <address | page key | item id>]", exitCodes.usage);
666
- const results = await api(await resolveConnection(options.apiUrl), `/api/v1/pages/results${reportQuery(options, { page: options.page })}`);
672
+ const results = await api(await resolveConnection(options), `/api/v1/pages/results${reportQuery(options, { page: options.page })}`);
667
673
  if (options.format === "json")
668
674
  return printJson(results);
669
675
  printPageResults(results);
@@ -674,14 +680,14 @@ const commands = {
674
680
  async run(args, options) {
675
681
  if (args.length > 0)
676
682
  throw new CliError("Usage: messages results [--from <date>] [--to <date>] [--channel email|text] [--sequence <workflow id>]", exitCodes.usage);
677
- const results = await api(await resolveConnection(options.apiUrl), `/api/v1/messages/results${reportQuery(options, { channel: options.channel, sequence: options.sequence })}`);
683
+ const results = await api(await resolveConnection(options), `/api/v1/messages/results${reportQuery(options, { channel: options.channel, sequence: options.sequence })}`);
678
684
  if (options.format === "json")
679
685
  return printJson(results);
680
686
  printMessageResults(results);
681
687
  },
682
688
  },
683
689
  winners: {
684
- usage: "winners [--week <YYYY-MM-DD> | --days <n> | --from <YYYY-MM-DD> --to <YYYY-MM-DD>] [--top <n>] [--ad-format image|video|carousel] (which Facebook ads and landing pages won and flopped, judged on real sales: first-touch, or Triple Whale's orders when the workspace has them)",
690
+ usage: "winners [--week <YYYY-MM-DD> | --days <n> | --from <YYYY-MM-DD> --to <YYYY-MM-DD>] [--top <n>] [--ad-format image|video|carousel] (which Facebook ads and landing pages won and flopped, judged on each client's goal; see winners goals)",
685
691
  async run(args, options) {
686
692
  if (args.length > 0)
687
693
  throw new CliError("Usage: winners [--week <date> | --days <n> | --from <date> --to <date>] [--top <n>] [--ad-format image|video|carousel]", exitCodes.usage);
@@ -689,7 +695,7 @@ const commands = {
689
695
  for (const [name, value] of Object.entries({ ...winnersPeriod(options), top: options.top, format: options.entryTags.format }))
690
696
  if (value !== undefined)
691
697
  query.set(name, value);
692
- const report = await api(await resolveConnection(options.apiUrl), `/api/v1/winners${query.size ? `?${query}` : ""}`);
698
+ const report = await api(await resolveConnection(options), `/api/v1/winners${query.size ? `?${query}` : ""}`);
693
699
  if (options.format === "json")
694
700
  return printJson(report);
695
701
  process.stdout.write(winnersText(report));
@@ -701,7 +707,7 @@ const commands = {
701
707
  if (args.length > 0)
702
708
  throw new CliError("Usage: winners send-to-exodus [--week <date> | --days <n> | --from <date> --to <date>] [--dry-run]", exitCodes.usage);
703
709
  const period = Object.entries(winnersPeriod(options)).filter(([, value]) => value !== undefined);
704
- const sent = await api(await resolveConnection(options.apiUrl), "/api/v1/winners/exodus", { ...Object.fromEntries(period), dryRun: options.dryRun });
710
+ const sent = await api(await resolveConnection(options), "/api/v1/winners/exodus", { ...Object.fromEntries(period), dryRun: options.dryRun });
705
711
  if (options.format === "json")
706
712
  printJson(sent);
707
713
  else
@@ -711,12 +717,36 @@ const commands = {
711
717
  throw new CliError(`${failed} of ${sent.pages.length} ${sent.pages.length === 1 ? "page was" : "pages were"} not sent; see above.`, exitCodes.error);
712
718
  },
713
719
  },
720
+ "winners goals": {
721
+ usage: "winners goals (the goals this client's ads are judged on, in order, whether each has data, and which one winners uses now)",
722
+ async run(args, options) {
723
+ if (args.length > 0)
724
+ throw new CliError("Usage: winners goals", exitCodes.usage);
725
+ printWinnersGoals(await api(await resolveConnection(options), "/api/v1/winners/goals"), options.format);
726
+ },
727
+ },
728
+ "winners goals set": {
729
+ usage: "winners goals set <goal> [<goal> ...] (judge this client's ads on these goals, the first with data first: sales, sales:triple_whale, booked_calls, meta_results, first_leads)",
730
+ async run(args, options) {
731
+ if (args.length === 0)
732
+ throw new CliError("Usage: winners goals set <goal> [<goal> ...]", exitCodes.usage);
733
+ printWinnersGoals(await api(await resolveConnection(options), "/api/v1/winners/goals", { goals: args }, "PUT"), options.format);
734
+ },
735
+ },
736
+ "winners goals reset": {
737
+ usage: "winners goals reset (go back to the default goals: Triple Whale's orders, then first-touch sales, then first leads)",
738
+ async run(args, options) {
739
+ if (args.length > 0)
740
+ throw new CliError("Usage: winners goals reset", exitCodes.usage);
741
+ printWinnersGoals(await api(await resolveConnection(options), "/api/v1/winners/goals", { goals: null }, "PUT"), options.format);
742
+ },
743
+ },
714
744
  examples: {
715
745
  usage: "examples (plain questions with SQL that answers them, for the tables this workspace has)",
716
746
  async run(args, options) {
717
747
  if (args.length > 0)
718
748
  throw new CliError("Usage: examples", exitCodes.usage);
719
- const { examples } = await api(await resolveConnection(options.apiUrl), "/api/v1/examples");
749
+ const { examples } = await api(await resolveConnection(options), "/api/v1/examples");
720
750
  if (options.format === "json")
721
751
  return printJson({ examples });
722
752
  for (const e of examples) {
@@ -734,7 +764,7 @@ const commands = {
734
764
  throw new CliError("Usage: content search [--text <words>] [--kind <kind>] [--sort <column>] [...]; put several words in quotes", exitCodes.usage);
735
765
  const filters = { text: options.text, mode: options.mode, kind: options.kind, platform: options.platform, origin: options.origin, from: options.from, to: options.to, sort: options.sort, order: options.order, limit: options.limit };
736
766
  const query = new URLSearchParams(Object.entries(filters).filter((entry) => entry[1] !== undefined));
737
- const { results } = await api(await resolveConnection(options.apiUrl), `/api/v1/content${query.size ? `?${query}` : ""}`);
767
+ const { results } = await api(await resolveConnection(options), `/api/v1/content${query.size ? `?${query}` : ""}`);
738
768
  if (options.format === "json")
739
769
  return printJson({ results });
740
770
  if (results.length === 0)
@@ -760,7 +790,7 @@ const commands = {
760
790
  async run(args, options) {
761
791
  if (args.length !== 1)
762
792
  throw new CliError("Usage: content show <sku | item-id>", exitCodes.usage);
763
- const content = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}`);
793
+ const content = await api(await resolveConnection(options), `/api/v1/content/${encodeURIComponent(args[0])}`);
764
794
  printContent(content, options.format);
765
795
  },
766
796
  },
@@ -770,7 +800,7 @@ const commands = {
770
800
  if (args.length !== 1)
771
801
  throw new CliError("Usage: content pull <dir> [--kind <kind>] [--platform <platform>] [--origin discovered|made] [--media] [--full]", exitCodes.usage);
772
802
  const filters = Object.fromEntries(Object.entries({ kind: options.kind, platform: options.platform, origin: options.origin }).filter((e) => e[1] !== undefined));
773
- const report = await pullContent(await resolveConnection(options.apiUrl), args[0], filters, { media: options.media, full: options.full });
803
+ const report = await pullContent(await resolveConnection(options), args[0], filters, { media: options.media, full: options.full });
774
804
  if (options.format === "json")
775
805
  return printJson(report);
776
806
  const media = options.media ? `, ${report.media} new media file${report.media === 1 ? "" : "s"}` : "";
@@ -786,7 +816,7 @@ const commands = {
786
816
  if (args.length !== 1)
787
817
  throw new CliError("Usage: content diff <sku | item-id> [--from <n>] [--to <n>]", exitCodes.usage);
788
818
  const query = new URLSearchParams(Object.entries({ from: options.from, to: options.to }).filter((entry) => entry[1] !== undefined));
789
- const diff = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/diff${query.size ? `?${query}` : ""}`);
819
+ const diff = await api(await resolveConnection(options), `/api/v1/content/${encodeURIComponent(args[0])}/diff${query.size ? `?${query}` : ""}`);
790
820
  if (options.format === "json")
791
821
  return printJson(diff);
792
822
  printContentDiff(diff);
@@ -797,7 +827,7 @@ const commands = {
797
827
  async run(args, options) {
798
828
  if (args.length !== 1)
799
829
  throw new CliError("Usage: content edit <sku | item-id> [--body <text> | @file] [--note <why>] [...]", exitCodes.usage);
800
- const connection = await resolveConnection(options.apiUrl);
830
+ const connection = await resolveConnection(options);
801
831
  const input = await contentInput(options, connection);
802
832
  if (Object.keys(input).every((k) => k === "runId" || k === "note")) {
803
833
  throw new CliError("content edit needs something to change, such as --body, --subject or --file.", exitCodes.usage);
@@ -811,7 +841,7 @@ const commands = {
811
841
  async run(args, options) {
812
842
  if (args.length !== 2)
813
843
  throw new CliError("Usage: content status <sku | item-id> <status> [--platform <platform> [--object-type <type>] --external-id <id>]", exitCodes.usage);
814
- const content = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/status`, {
844
+ const content = await api(await resolveConnection(options), `/api/v1/content/${encodeURIComponent(args[0])}/status`, {
815
845
  status: args[1],
816
846
  ...(options.platform === undefined ? {} : { platform: options.platform }),
817
847
  ...(options.objectType === undefined ? {} : { objectType: options.objectType }),
@@ -829,7 +859,7 @@ const commands = {
829
859
  const version = options.version === undefined ? undefined : Number(options.version);
830
860
  if (version !== undefined && !(Number.isInteger(version) && version >= 1))
831
861
  throw new CliError("--version must be a whole number from 1.", exitCodes.usage);
832
- const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/publish`, {
862
+ const { publication } = await api(await resolveConnection(options), `/api/v1/content/${encodeURIComponent(args[0])}/publish`, {
833
863
  publisher: options.to,
834
864
  target: await parseJson(options.target, "--target"),
835
865
  idempotencyKey: options.idempotencyKey,
@@ -845,7 +875,7 @@ const commands = {
845
875
  async run(args, options) {
846
876
  if (args.length !== 1)
847
877
  throw new CliError("Usage: content reconcile <publication-id>", exitCodes.usage);
848
- const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/publications/${encodeURIComponent(args[0])}/reconcile`, {});
878
+ const { publication } = await api(await resolveConnection(options), `/api/v1/publications/${encodeURIComponent(args[0])}/reconcile`, {});
849
879
  printPublication(publication, options.format);
850
880
  },
851
881
  },
@@ -855,7 +885,7 @@ const commands = {
855
885
  if (args.length !== 0 || options.plan === undefined || !options.idempotencyKey) {
856
886
  throw new CliError("Usage: meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>]", exitCodes.usage);
857
887
  }
858
- const { adsetPublication } = await api(await resolveConnection(options.apiUrl), "/api/v1/meta/adsets", {
888
+ const { adsetPublication } = await api(await resolveConnection(options), "/api/v1/meta/adsets", {
859
889
  plan: await parseJson(options.plan, "--plan"),
860
890
  idempotencyKey: options.idempotencyKey,
861
891
  ...(options.run === undefined ? {} : { runId: options.run }),
@@ -868,7 +898,7 @@ const commands = {
868
898
  async run(args, options) {
869
899
  if (args.length !== 1)
870
900
  throw new CliError("Usage: meta adset show <adset-publication-id>", exitCodes.usage);
871
- const { adsetPublication } = await api(await resolveConnection(options.apiUrl), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}`);
901
+ const { adsetPublication } = await api(await resolveConnection(options), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}`);
872
902
  printAdSetPublication(adsetPublication, options.format);
873
903
  },
874
904
  },
@@ -877,7 +907,7 @@ const commands = {
877
907
  async run(args, options) {
878
908
  if (args.length !== 1)
879
909
  throw new CliError("Usage: meta adset reconcile <adset-publication-id>", exitCodes.usage);
880
- const { adsetPublication } = await api(await resolveConnection(options.apiUrl), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}/reconcile`, {});
910
+ const { adsetPublication } = await api(await resolveConnection(options), `/api/v1/meta/adsets/${encodeURIComponent(args[0])}/reconcile`, {});
881
911
  printAdSetPublication(adsetPublication, options.format);
882
912
  },
883
913
  },
@@ -886,7 +916,7 @@ const commands = {
886
916
  async run(args, options) {
887
917
  if (args.length !== 1)
888
918
  throw new CliError("Usage: content unpublish <deployment-id> [--force]", exitCodes.usage);
889
- const { deployment } = await api(await resolveConnection(options.apiUrl), `/api/v1/deployments/${encodeURIComponent(args[0])}/unpublish`, options.force ? { force: true } : {});
919
+ const { deployment } = await api(await resolveConnection(options), `/api/v1/deployments/${encodeURIComponent(args[0])}/unpublish`, options.force ? { force: true } : {});
890
920
  if (options.format === "json")
891
921
  return printJson({ deployment });
892
922
  printFields({
@@ -904,7 +934,7 @@ const commands = {
904
934
  async run(args, options) {
905
935
  if (args.length !== 1)
906
936
  throw new CliError("Usage: content publish-record <sku | item-id> --file <publication.json>", exitCodes.usage);
907
- const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/publications`, await readToolInput(options));
937
+ const { publication } = await api(await resolveConnection(options), `/api/v1/content/${encodeURIComponent(args[0])}/publications`, await readToolInput(options));
908
938
  printPublication(publication, options.format);
909
939
  },
910
940
  },
@@ -915,7 +945,7 @@ const commands = {
915
945
  throw new CliError("Usage: content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--file <outcome.json>]", exitCodes.usage);
916
946
  }
917
947
  const outcome = await readToolInput(options);
918
- const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/publications/${encodeURIComponent(args[0])}`, { ...outcome, status: options.status }, "PATCH");
948
+ const { publication } = await api(await resolveConnection(options), `/api/v1/publications/${encodeURIComponent(args[0])}`, { ...outcome, status: options.status }, "PATCH");
919
949
  printPublication(publication, options.format);
920
950
  },
921
951
  },
@@ -924,7 +954,7 @@ const commands = {
924
954
  async run(args, options) {
925
955
  if (args.length > 0)
926
956
  throw new CliError("Usage: agent start", exitCodes.usage);
927
- const start = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs`, {});
957
+ const start = await api(await resolveConnection(options), `${agentPath(options)}/runs`, {});
928
958
  if (options.format === "json")
929
959
  return printJson(start);
930
960
  const { run, quota, brandProfile } = start;
@@ -944,7 +974,7 @@ const commands = {
944
974
  async run(args, options) {
945
975
  if (args.length !== 1)
946
976
  throw new CliError("Usage: agent show <run-id>", exitCodes.usage);
947
- const shown = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs/${encodeURIComponent(args[0])}`);
977
+ const shown = await api(await resolveConnection(options), `${agentPath(options)}/runs/${encodeURIComponent(args[0])}`);
948
978
  if (options.format === "json")
949
979
  return printJson(shown);
950
980
  const { run, proposals } = shown;
@@ -958,7 +988,7 @@ const commands = {
958
988
  usage: "agent runs [--limit <n>]",
959
989
  async run(_args, options) {
960
990
  const query = options.limit ? `?limit=${encodeURIComponent(options.limit)}` : "";
961
- const { runs } = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs${query}`);
991
+ const { runs } = await api(await resolveConnection(options), `${agentPath(options)}/runs${query}`);
962
992
  if (options.format === "json")
963
993
  return printJson({ runs });
964
994
  if (runs.length === 0)
@@ -973,7 +1003,7 @@ const commands = {
973
1003
  throw new CliError("Usage: agent finish <run-id> --status succeeded|failed [--file report.md]", exitCodes.usage);
974
1004
  }
975
1005
  const report = await readText(options.file, "the report");
976
- const { run } = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs/${encodeURIComponent(args[0])}/finish`, { status: options.status, report });
1006
+ const { run } = await api(await resolveConnection(options), `${agentPath(options)}/runs/${encodeURIComponent(args[0])}/finish`, { status: options.status, report });
977
1007
  if (options.format === "json")
978
1008
  return printJson({ run });
979
1009
  console.log(`Finished run ${run.id} as ${run.status}.`);
@@ -984,7 +1014,7 @@ const commands = {
984
1014
  async run(args, options) {
985
1015
  if (args.length > 0)
986
1016
  throw new CliError("Usage: agent draft --agent p3_refresh", exitCodes.usage);
987
- const body = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/draft`);
1017
+ const body = await api(await resolveConnection(options), `${agentPath(options)}/draft`);
988
1018
  if (options.format === "json")
989
1019
  return printJson(body);
990
1020
  const { draft } = body;
@@ -1025,7 +1055,7 @@ const commands = {
1025
1055
  "brand get": {
1026
1056
  usage: "brand get",
1027
1057
  async run(_args, options) {
1028
- const { profile } = await api(await resolveConnection(options.apiUrl), "/api/v1/brand-profile");
1058
+ const { profile } = await api(await resolveConnection(options), "/api/v1/brand-profile");
1029
1059
  if (options.format === "json")
1030
1060
  return printJson({ profile });
1031
1061
  if (!profile)
@@ -1041,7 +1071,7 @@ const commands = {
1041
1071
  throw new CliError("Usage: brand set [--file <path>] [--sources '<json>' | @file.json]", exitCodes.usage);
1042
1072
  const markdown = await readText(options.file, "the brand profile");
1043
1073
  const sources = options.sources === undefined ? [] : await parseJson(options.sources, "--sources");
1044
- const { profile } = await api(await resolveConnection(options.apiUrl), "/api/v1/brand-profile", { markdown, sources }, "PUT");
1074
+ const { profile } = await api(await resolveConnection(options), "/api/v1/brand-profile", { markdown, sources }, "PUT");
1045
1075
  if (options.format === "json")
1046
1076
  return printJson({ profile });
1047
1077
  console.log(`Saved brand profile version ${profile.version}.`);
@@ -1052,7 +1082,7 @@ const commands = {
1052
1082
  async run(args, options) {
1053
1083
  if (args.length !== 1)
1054
1084
  throw new CliError("Usage: content add-page <url>", exitCodes.usage);
1055
- const { page } = await api(await resolveConnection(options.apiUrl), "/api/v1/content/pages", { url: args[0] });
1085
+ const { page } = await api(await resolveConnection(options), "/api/v1/content/pages", { url: args[0] });
1056
1086
  if (options.format === "json")
1057
1087
  return printJson({ page });
1058
1088
  console.log(renderTable(["key", "status", "version", "changed", "headline", "blocks"], [{ ...page, blocks: page.blockCount }]));
@@ -1066,7 +1096,7 @@ const commands = {
1066
1096
  async run(args, options) {
1067
1097
  if (args.length !== 2)
1068
1098
  throw new CliError("Usage: content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>]", exitCodes.usage);
1069
- const { url } = await api(await resolveConnection(options.apiUrl), "/api/v1/content/track-link", {
1099
+ const { url } = await api(await resolveConnection(options), "/api/v1/content/track-link", {
1070
1100
  sku: args[0],
1071
1101
  url: args[1],
1072
1102
  channel: options.channel,
@@ -1082,7 +1112,7 @@ const commands = {
1082
1112
  async run(args, options) {
1083
1113
  if (args.length !== 1)
1084
1114
  throw new CliError("Usage: trail <plan-id | sku | item-id>", exitCodes.usage);
1085
- const connection = await resolveConnection(options.apiUrl);
1115
+ const connection = await resolveConnection(options);
1086
1116
  const ref = args[0];
1087
1117
  const trailOf = (query) => api(connection, `/api/v1/trail?${query}=${encodeURIComponent(ref)}`);
1088
1118
  // Plans and items both have uuid ids: an id that names no plan is read as an item.
@@ -1102,7 +1132,7 @@ const commands = {
1102
1132
  if (args.length > 0)
1103
1133
  throw new CliError("Usage: funnels show [--version <n>]", exitCodes.usage);
1104
1134
  const query = options.version === undefined ? "" : `?version=${wholeNumber(options.version, "--version")}`;
1105
- const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}${query}`);
1135
+ const view = await api(await resolveConnection(options), `${FUNNELS_PATH}${query}`);
1106
1136
  if (options.format === "json")
1107
1137
  return printJson(view);
1108
1138
  process.stdout.write(view.text);
@@ -1113,7 +1143,7 @@ const commands = {
1113
1143
  async run(args, options) {
1114
1144
  if (args.length > 0)
1115
1145
  throw new CliError("Usage: funnels numbers [--funnel <key>] [--from <date>] [--to <date>]", exitCodes.usage);
1116
- const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}/numbers${reportQuery(options, { funnel: options.funnel })}`);
1146
+ const view = await api(await resolveConnection(options), `${FUNNELS_PATH}/numbers${reportQuery(options, { funnel: options.funnel })}`);
1117
1147
  if (options.format === "json")
1118
1148
  return printJson(view);
1119
1149
  process.stdout.write(view.text);
@@ -1124,7 +1154,7 @@ const commands = {
1124
1154
  async run(args, options) {
1125
1155
  if (args.length > 0 || options.from !== undefined)
1126
1156
  throw new CliError("Usage: funnels leaks [--funnel <key>] [--to <date>]", exitCodes.usage);
1127
- const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}/leaks${reportQuery(options, { funnel: options.funnel })}`);
1157
+ const view = await api(await resolveConnection(options), `${FUNNELS_PATH}/leaks${reportQuery(options, { funnel: options.funnel })}`);
1128
1158
  if (options.format === "json")
1129
1159
  return printJson(view);
1130
1160
  process.stdout.write(view.text);
@@ -1135,7 +1165,7 @@ const commands = {
1135
1165
  async run(args, options) {
1136
1166
  if (args.length > 0)
1137
1167
  throw new CliError("Usage: funnels evidence", exitCodes.usage);
1138
- const view = await api(await resolveConnection(options.apiUrl), `${FUNNELS_PATH}/evidence`);
1168
+ const view = await api(await resolveConnection(options), `${FUNNELS_PATH}/evidence`);
1139
1169
  if (options.format === "json")
1140
1170
  return printJson(view);
1141
1171
  process.stdout.write(view.text);
@@ -1153,7 +1183,7 @@ const commands = {
1153
1183
  const map = await parseJson(`@${options.file}`, "--file");
1154
1184
  const evidence = options.evidence === undefined ? undefined : await parseJson(options.evidence, "--evidence");
1155
1185
  const questions = options.questions === undefined ? undefined : await parseJson(options.questions, "--questions");
1156
- const connection = await resolveConnection(options.apiUrl);
1186
+ const connection = await resolveConnection(options);
1157
1187
  const basedOn = options.basedOn === undefined ? null : wholeNumber(options.basedOn, "--based-on");
1158
1188
  const saved = await api(connection, `${FUNNELS_PATH}/draft`, { map, note: options.note, evidence, based_on: basedOn, counts_from: options.countsFrom, questions }, "PUT");
1159
1189
  if (options.format === "json")
@@ -1183,7 +1213,7 @@ const commands = {
1183
1213
  "funnels versions": {
1184
1214
  usage: "funnels versions [--diff <a> <b>] every version of the map, or what changed from version a to b",
1185
1215
  async run(args, options) {
1186
- const connection = await resolveConnection(options.apiUrl);
1216
+ const connection = await resolveConnection(options);
1187
1217
  if (options.diff !== undefined) {
1188
1218
  if (args.length !== 1)
1189
1219
  throw new CliError("Usage: funnels versions --diff <a> <b>", exitCodes.usage);
@@ -1230,7 +1260,7 @@ const commands = {
1230
1260
  }
1231
1261
  if (options.status)
1232
1262
  query.set("status", options.status);
1233
- const { facts } = await api(await resolveConnection(options.apiUrl), `${FACTS_PATH}${query.size ? `?${query}` : ""}`);
1263
+ const { facts } = await api(await resolveConnection(options), `${FACTS_PATH}${query.size ? `?${query}` : ""}`);
1234
1264
  if (options.format === "json")
1235
1265
  return printJson({ facts });
1236
1266
  if (facts.length === 0)
@@ -1252,7 +1282,7 @@ const commands = {
1252
1282
  query.set("sort", options.sort);
1253
1283
  if (options.limit)
1254
1284
  query.set("limit", options.limit);
1255
- const read = await api(await resolveConnection(options.apiUrl), `${VOICE_PATH}${query.size ? `?${query}` : ""}`);
1285
+ const read = await api(await resolveConnection(options), `${VOICE_PATH}${query.size ? `?${query}` : ""}`);
1256
1286
  if (options.format === "json")
1257
1287
  return printJson(read);
1258
1288
  if ("lines" in read) {
@@ -1277,7 +1307,7 @@ const commands = {
1277
1307
  query.set("status", options.status);
1278
1308
  if (options.limit)
1279
1309
  query.set("limit", options.limit);
1280
- const { tags } = await api(await resolveConnection(options.apiUrl), `${AD_TAGS_PATH}${query.size ? `?${query}` : ""}`);
1310
+ const { tags } = await api(await resolveConnection(options), `${AD_TAGS_PATH}${query.size ? `?${query}` : ""}`);
1281
1311
  if (options.format === "json")
1282
1312
  return printJson({ tags });
1283
1313
  if (tags.length === 0)
@@ -1290,7 +1320,7 @@ const commands = {
1290
1320
  async run(args, options) {
1291
1321
  if (args.length > 0)
1292
1322
  throw new CliError("Usage: ads tag --file <tags.json> | < tags.json", exitCodes.usage);
1293
- const result = await api(await resolveConnection(options.apiUrl), AD_TAGS_PATH, await readToolInput(options));
1323
+ const result = await api(await resolveConnection(options), AD_TAGS_PATH, await readToolInput(options));
1294
1324
  if (options.format === "json")
1295
1325
  return printJson(result);
1296
1326
  console.log(`Proposed tags for ${result.proposed.length} ${result.proposed.length === 1 ? "ad" : "ads"}. A person confirms or rejects them in the app.`);
@@ -1304,7 +1334,7 @@ const commands = {
1304
1334
  if (args.length > 0)
1305
1335
  throw new CliError("Usage: ads autotag [--limit <n>] [--ad <id>] [--json]", exitCodes.usage);
1306
1336
  const body = { ...(options.ad ? { ad_ids: [options.ad] } : {}), ...(options.limit ? { limit: Number(options.limit) } : {}) };
1307
- const result = await api(await resolveConnection(options.apiUrl), AD_AUTOTAG_PATH, body);
1337
+ const result = await api(await resolveConnection(options), AD_AUTOTAG_PATH, body);
1308
1338
  if (options.format === "json")
1309
1339
  return printJson(result);
1310
1340
  const plural = (n) => `${n} ${n === 1 ? "ad" : "ads"}`;
@@ -1331,7 +1361,7 @@ const commands = {
1331
1361
  async run(args, options) {
1332
1362
  if (args.length > 0)
1333
1363
  throw new CliError("Usage: ads tag-formats [--limit <n>] [--json]", exitCodes.usage);
1334
- const connection = await resolveConnection(options.apiUrl);
1364
+ const connection = await resolveConnection(options);
1335
1365
  const total = { tagged: 0, no_format: 0 };
1336
1366
  let after = null;
1337
1367
  for (;;) {
@@ -1360,7 +1390,7 @@ const commands = {
1360
1390
  throw new CliError("Usage: content tags [<item-id>] [--kind email|text|page] [--status <status>] [--limit <n>] [--json]", exitCodes.usage);
1361
1391
  const filters = { item: args[0], kind: options.kind, status: options.status, limit: options.limit };
1362
1392
  const query = new URLSearchParams(Object.entries(filters).filter((entry) => entry[1] !== undefined));
1363
- const { tags } = await api(await resolveConnection(options.apiUrl), `${CONTENT_TAGS_PATH}${query.size ? `?${query}` : ""}`);
1393
+ const { tags } = await api(await resolveConnection(options), `${CONTENT_TAGS_PATH}${query.size ? `?${query}` : ""}`);
1364
1394
  if (options.format === "json")
1365
1395
  return printJson({ tags });
1366
1396
  if (tags.length === 0)
@@ -1374,7 +1404,7 @@ const commands = {
1374
1404
  if (args.length > 0)
1375
1405
  throw new CliError("Usage: content to-check [--limit <n>] [--json]", exitCodes.usage);
1376
1406
  const query = new URLSearchParams({ to_check: "1", ...(options.limit ? { limit: options.limit } : {}) });
1377
- const { tags } = await api(await resolveConnection(options.apiUrl), `${CONTENT_TAGS_PATH}?${query}`);
1407
+ const { tags } = await api(await resolveConnection(options), `${CONTENT_TAGS_PATH}?${query}`);
1378
1408
  if (options.format === "json")
1379
1409
  return printJson({ tags });
1380
1410
  if (tags.length === 0)
@@ -1390,7 +1420,7 @@ const commands = {
1390
1420
  ...(options.kind ? { kinds: options.kind.split(",") } : {}),
1391
1421
  ...(options.limit ? { limit: Number(options.limit) } : {}),
1392
1422
  };
1393
- const result = await api(await resolveConnection(options.apiUrl), CONTENT_AUTOTAG_PATH, body);
1423
+ const result = await api(await resolveConnection(options), CONTENT_AUTOTAG_PATH, body);
1394
1424
  if (options.format === "json")
1395
1425
  return printJson(result);
1396
1426
  const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
@@ -1415,7 +1445,7 @@ const commands = {
1415
1445
  async run(args, options) {
1416
1446
  if (args.length !== 1)
1417
1447
  throw new CliError("Usage: facts show <id>", exitCodes.usage);
1418
- const { fact } = await api(await resolveConnection(options.apiUrl), `${FACTS_PATH}/${encodeURIComponent(args[0])}`);
1448
+ const { fact } = await api(await resolveConnection(options), `${FACTS_PATH}/${encodeURIComponent(args[0])}`);
1419
1449
  if (options.format === "json")
1420
1450
  return printJson({ fact });
1421
1451
  const nameKey = fact.key === NAME_KEY ? nameKeyLines(fact.value) : null;
@@ -1430,7 +1460,7 @@ const commands = {
1430
1460
  async run(args, options) {
1431
1461
  if (args.length > 0)
1432
1462
  throw new CliError("Usage: facts propose --file <fact.json> | < fact.json", exitCodes.usage);
1433
- const { fact } = await api(await resolveConnection(options.apiUrl), FACTS_PATH, await readToolInput(options));
1463
+ const { fact } = await api(await resolveConnection(options), FACTS_PATH, await readToolInput(options));
1434
1464
  if (options.format === "json")
1435
1465
  return printJson({ fact });
1436
1466
  console.log(`Proposed fact ${fact.id}. A person confirms or rejects it in the app.`);
@@ -1450,7 +1480,7 @@ const commands = {
1450
1480
  for (const dimension of options.dimension ?? [])
1451
1481
  params.append("dimension", dimension);
1452
1482
  const query = params.size > 0 ? `?${params}` : "";
1453
- const { lessons } = await api(await resolveConnection(options.apiUrl), `${LESSONS_PATH}${query}`);
1483
+ const { lessons } = await api(await resolveConnection(options), `${LESSONS_PATH}${query}`);
1454
1484
  if (options.format === "json")
1455
1485
  return printJson({ lessons });
1456
1486
  if (lessons.length === 0) {
@@ -1465,7 +1495,7 @@ const commands = {
1465
1495
  async run(args, options) {
1466
1496
  if (args.length > 0)
1467
1497
  throw new CliError("Usage: lessons add --file <lesson.json> | < lesson.json", exitCodes.usage);
1468
- const recorded = await api(await resolveConnection(options.apiUrl), LESSONS_PATH, await readToolInput(options));
1498
+ const recorded = await api(await resolveConnection(options), LESSONS_PATH, await readToolInput(options));
1469
1499
  if (options.format === "json")
1470
1500
  return printJson(recorded);
1471
1501
  const { lesson, outcome } = recorded;
@@ -1489,7 +1519,7 @@ const commands = {
1489
1519
  query.set("funnel", options.funnel);
1490
1520
  if (options.limit)
1491
1521
  query.set("limit", options.limit);
1492
- const { tests } = await api(await resolveConnection(options.apiUrl), `${TESTS_PATH}${query.size ? `?${query}` : ""}`);
1522
+ const { tests } = await api(await resolveConnection(options), `${TESTS_PATH}${query.size ? `?${query}` : ""}`);
1493
1523
  if (options.format === "json")
1494
1524
  return printJson({ tests });
1495
1525
  if (tests.length === 0)
@@ -1502,7 +1532,7 @@ const commands = {
1502
1532
  async run(args, options) {
1503
1533
  if (args.length !== 1)
1504
1534
  throw new CliError("Usage: tests show <id|test_id>", exitCodes.usage);
1505
- const { test } = await api(await resolveConnection(options.apiUrl), testPath(args[0]));
1535
+ const { test } = await api(await resolveConnection(options), testPath(args[0]));
1506
1536
  if (options.format === "json")
1507
1537
  return printJson({ test });
1508
1538
  printTest(test);
@@ -1513,7 +1543,7 @@ const commands = {
1513
1543
  async run(args, options) {
1514
1544
  if (args.length > 0)
1515
1545
  throw new CliError("Usage: tests draft --file <test.json> | < test.json", exitCodes.usage);
1516
- const { test } = await api(await resolveConnection(options.apiUrl), TESTS_PATH, await readToolInput(options));
1546
+ const { test } = await api(await resolveConnection(options), TESTS_PATH, await readToolInput(options));
1517
1547
  if (options.format === "json")
1518
1548
  return printJson({ test });
1519
1549
  console.log(`Drafted test ${test.test_id} (${test.id}).`);
@@ -1524,7 +1554,7 @@ const commands = {
1524
1554
  async run(args, options) {
1525
1555
  if (args.length !== 1)
1526
1556
  throw new CliError("Usage: tests start <id|test_id>", exitCodes.usage);
1527
- const { test } = await api(await resolveConnection(options.apiUrl), `${testPath(args[0])}/start`, {});
1557
+ const { test } = await api(await resolveConnection(options), `${testPath(args[0])}/start`, {});
1528
1558
  if (options.format === "json")
1529
1559
  return printJson({ test });
1530
1560
  console.log(`Test ${test.test_id} is running from ${test.started_on}.`);
@@ -1535,7 +1565,7 @@ const commands = {
1535
1565
  async run(args, options) {
1536
1566
  if (args.length !== 1)
1537
1567
  throw new CliError('Usage: tests abandon <id|test_id> [--note "<why>"]', exitCodes.usage);
1538
- const { test } = await api(await resolveConnection(options.apiUrl), `${testPath(args[0])}/abandon`, options.note ? { note: options.note } : {});
1568
+ const { test } = await api(await resolveConnection(options), `${testPath(args[0])}/abandon`, options.note ? { note: options.note } : {});
1539
1569
  if (options.format === "json")
1540
1570
  return printJson({ test });
1541
1571
  console.log(`Abandoned test ${test.test_id}.`);
@@ -1546,7 +1576,7 @@ const commands = {
1546
1576
  async run(args, options) {
1547
1577
  if (args.length !== 1)
1548
1578
  throw new CliError("Usage: tests results <id|test_id>", exitCodes.usage);
1549
- const { results } = await api(await resolveConnection(options.apiUrl), `${testPath(args[0])}/results`);
1579
+ const { results } = await api(await resolveConnection(options), `${testPath(args[0])}/results`);
1550
1580
  if (options.format === "json")
1551
1581
  return printJson({ results });
1552
1582
  printResults(results);
@@ -1558,7 +1588,7 @@ const commands = {
1558
1588
  if (args.length !== 1 || !options.decision) {
1559
1589
  throw new CliError('Usage: tests decide <id|test_id> --decision <scale|continue|iterate|hold|stop> [--note "<why>"]', exitCodes.usage);
1560
1590
  }
1561
- const decided = await api(await resolveConnection(options.apiUrl), `${testPath(args[0])}/decide`, options.note ? { decision: options.decision, note: options.note } : { decision: options.decision });
1591
+ const decided = await api(await resolveConnection(options), `${testPath(args[0])}/decide`, options.note ? { decision: options.decision, note: options.note } : { decision: options.decision });
1562
1592
  if (options.format === "json")
1563
1593
  return printJson(decided);
1564
1594
  const { test, lesson, next_test: next } = decided;
@@ -1574,7 +1604,7 @@ const commands = {
1574
1604
  async run(args, options) {
1575
1605
  if (args.length !== 1 || !options.note)
1576
1606
  throw new CliError('Usage: lessons retire <id> --note "<why it is no longer true>"', exitCodes.usage);
1577
- const { lesson } = await api(await resolveConnection(options.apiUrl), `${LESSONS_PATH}/${encodeURIComponent(args[0])}/retire`, { reason: options.note });
1607
+ const { lesson } = await api(await resolveConnection(options), `${LESSONS_PATH}/${encodeURIComponent(args[0])}/retire`, { reason: options.note });
1578
1608
  if (options.format === "json")
1579
1609
  return printJson({ lesson });
1580
1610
  console.log(`Retired lesson ${lesson.id}.`);
@@ -1585,7 +1615,7 @@ const commands = {
1585
1615
  async run(args, options) {
1586
1616
  if (args.length > 0)
1587
1617
  throw new CliError("Usage: grammar | grammar propose --file <values.json>", exitCodes.usage);
1588
- const answer = await api(await resolveConnection(options.apiUrl), GRAMMAR_PATH);
1618
+ const answer = await api(await resolveConnection(options), GRAMMAR_PATH);
1589
1619
  if (options.format === "json")
1590
1620
  return printJson(answer);
1591
1621
  process.stdout.write(answer.text);
@@ -1596,7 +1626,7 @@ const commands = {
1596
1626
  async run(args, options) {
1597
1627
  if (args.length > 0)
1598
1628
  throw new CliError("Usage: grammar propose --file <values.json> | < values.json", exitCodes.usage);
1599
- const result = await api(await resolveConnection(options.apiUrl), `${GRAMMAR_PATH}/proposals`, await readToolInput(options));
1629
+ const result = await api(await resolveConnection(options), `${GRAMMAR_PATH}/proposals`, await readToolInput(options));
1600
1630
  if (options.format === "json")
1601
1631
  return printJson(result);
1602
1632
  const named = (refs) => refs.map((r) => `${r.part} ${r.key}`).join(", ");
@@ -1610,7 +1640,7 @@ const commands = {
1610
1640
  async run(args, options) {
1611
1641
  if (args.length > 0)
1612
1642
  throw new CliError("Usage: voice lists", exitCodes.usage);
1613
- const lists = await api(await resolveConnection(options.apiUrl), VOICE_LISTS_PATH);
1643
+ const lists = await api(await resolveConnection(options), VOICE_LISTS_PATH);
1614
1644
  if (options.format === "json")
1615
1645
  return printJson(lists);
1616
1646
  const rows = [
@@ -1631,7 +1661,7 @@ const commands = {
1631
1661
  if (!kind || !["competitor", "subreddit", "search_phrase"].includes(kind) || value === undefined || rest.length > 0) {
1632
1662
  throw new CliError('Usage: voice suggest competitor|subreddit|search_phrase "<value>" [--note <why>]; put a value with spaces in quotes', exitCodes.usage);
1633
1663
  }
1634
- const result = await api(await resolveConnection(options.apiUrl), VOICE_SUGGESTIONS_PATH, { kind, value, note: options.note });
1664
+ const result = await api(await resolveConnection(options), VOICE_SUGGESTIONS_PATH, { kind, value, note: options.note });
1635
1665
  if (options.format === "json")
1636
1666
  return printJson(result);
1637
1667
  if (result.outcome === "already_listed")
@@ -1646,7 +1676,7 @@ const commands = {
1646
1676
  async run(args, options) {
1647
1677
  if (args.length > 0)
1648
1678
  throw new CliError("Usage: story | story list | story show <version> | story submit --file <story.json>", exitCodes.usage);
1649
- const { story } = await api(await resolveConnection(options.apiUrl), `${STORIES_PATH}/latest`);
1679
+ const { story } = await api(await resolveConnection(options), `${STORIES_PATH}/latest`);
1650
1680
  if (options.format === "json")
1651
1681
  return printJson({ story });
1652
1682
  if (!story)
@@ -1659,7 +1689,7 @@ const commands = {
1659
1689
  async run(args, options) {
1660
1690
  if (args.length > 0)
1661
1691
  throw new CliError("Usage: story list", exitCodes.usage);
1662
- const { stories } = await api(await resolveConnection(options.apiUrl), STORIES_PATH);
1692
+ const { stories } = await api(await resolveConnection(options), STORIES_PATH);
1663
1693
  if (options.format === "json")
1664
1694
  return printJson({ stories });
1665
1695
  if (stories.length === 0)
@@ -1682,7 +1712,7 @@ const commands = {
1682
1712
  if (args.length !== 1)
1683
1713
  throw new CliError("Usage: story show <version>", exitCodes.usage);
1684
1714
  // `show latest` reaches the newest-story route, which answers null before the first story.
1685
- const { story } = await api(await resolveConnection(options.apiUrl), `${STORIES_PATH}/${encodeURIComponent(args[0])}`);
1715
+ const { story } = await api(await resolveConnection(options), `${STORIES_PATH}/${encodeURIComponent(args[0])}`);
1686
1716
  if (options.format === "json")
1687
1717
  return printJson({ story });
1688
1718
  if (!story)
@@ -1695,7 +1725,7 @@ const commands = {
1695
1725
  async run(args, options) {
1696
1726
  if (args.length > 0)
1697
1727
  throw new CliError("Usage: story submit --file <story.json> | < story.json", exitCodes.usage);
1698
- const { story } = await api(await resolveConnection(options.apiUrl), STORIES_PATH, await readToolInput(options));
1728
+ const { story } = await api(await resolveConnection(options), STORIES_PATH, await readToolInput(options));
1699
1729
  if (options.format === "json")
1700
1730
  return printJson({ story });
1701
1731
  console.log(`Saved story version ${story.version}.`);
@@ -1707,7 +1737,7 @@ const commands = {
1707
1737
  if (args.length > 0 || !options.name || !options.goal || !options.channel) {
1708
1738
  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);
1709
1739
  }
1710
- const { plan } = await api(await resolveConnection(options.apiUrl), "/api/v1/launch-plans", {
1740
+ const { plan } = await api(await resolveConnection(options), "/api/v1/launch-plans", {
1711
1741
  name: options.name,
1712
1742
  goal: await textOrFile(options.goal),
1713
1743
  channel: options.channel,
@@ -1726,7 +1756,7 @@ const commands = {
1726
1756
  async run(args, options) {
1727
1757
  if (args.length !== 1)
1728
1758
  throw new CliError("Usage: plans show <plan-id>", exitCodes.usage);
1729
- const { plan } = await api(await resolveConnection(options.apiUrl), planPath(args[0]));
1759
+ const { plan } = await api(await resolveConnection(options), planPath(args[0]));
1730
1760
  printPlan(plan, options.format);
1731
1761
  },
1732
1762
  },
@@ -1738,7 +1768,7 @@ const commands = {
1738
1768
  const position = options.position === undefined ? undefined : Number(options.position);
1739
1769
  if (position !== undefined && (!Number.isInteger(position) || position < 0))
1740
1770
  throw new CliError("--position must be a whole number from 0.", exitCodes.usage);
1741
- const { plan } = await api(await resolveConnection(options.apiUrl), `${planPath(args[0])}/items`, {
1771
+ const { plan } = await api(await resolveConnection(options), `${planPath(args[0])}/items`, {
1742
1772
  item: args[1],
1743
1773
  ...(options.role === undefined ? {} : { role: options.role }),
1744
1774
  ...(position === undefined ? {} : { position }),
@@ -1752,7 +1782,7 @@ const commands = {
1752
1782
  if (args.length !== 2)
1753
1783
  throw new CliError("Usage: plans remove <plan-id> <sku | item-id>", exitCodes.usage);
1754
1784
  const path = `${planPath(args[0])}/items?item=${encodeURIComponent(args[1])}`;
1755
- const { plan } = await api(await resolveConnection(options.apiUrl), path, undefined, "DELETE");
1785
+ const { plan } = await api(await resolveConnection(options), path, undefined, "DELETE");
1756
1786
  printPlan(plan, options.format);
1757
1787
  },
1758
1788
  },
@@ -1761,7 +1791,7 @@ const commands = {
1761
1791
  async run(args, options) {
1762
1792
  if (args.length !== 2)
1763
1793
  throw new CliError("Usage: plans status <plan-id> draft|ready|publishing|live|done|cancelled", exitCodes.usage);
1764
- const { plan } = await api(await resolveConnection(options.apiUrl), planPath(args[0]), { status: args[1] }, "PATCH");
1794
+ const { plan } = await api(await resolveConnection(options), planPath(args[0]), { status: args[1] }, "PATCH");
1765
1795
  printPlan(plan, options.format);
1766
1796
  },
1767
1797
  },
@@ -1770,7 +1800,7 @@ const commands = {
1770
1800
  async run(args, options) {
1771
1801
  if (args.length > 0)
1772
1802
  throw new CliError("Usage: proposals create --input '<json>'", exitCodes.usage);
1773
- const { proposal } = await api(await resolveConnection(options.apiUrl), "/api/v1/proposals", await readToolInput(options));
1803
+ const { proposal } = await api(await resolveConnection(options), "/api/v1/proposals", await readToolInput(options));
1774
1804
  if (options.format === "json")
1775
1805
  return printJson({ proposal });
1776
1806
  console.log(renderTable(PROPOSAL_COLUMNS, [proposalRow(proposal)]));
@@ -1789,7 +1819,7 @@ const commands = {
1789
1819
  if (options.limit)
1790
1820
  query.set("limit", options.limit);
1791
1821
  const path = `/api/v1/proposals${query.size ? `?${query}` : ""}`;
1792
- const { proposals } = await api(await resolveConnection(options.apiUrl), path);
1822
+ const { proposals } = await api(await resolveConnection(options), path);
1793
1823
  if (options.format === "json")
1794
1824
  return printJson({ proposals });
1795
1825
  if (proposals.length === 0)
@@ -1817,7 +1847,7 @@ const commands = {
1817
1847
  if (options.harness !== undefined)
1818
1848
  query.set("harnesses", options.harness);
1819
1849
  const path = `/api/operator/skills/${name}/versions${query.size ? `?${query}` : ""}`;
1820
- const published = await apiUpload(await operatorConnection(options.apiUrl), path, zip, "application/zip");
1850
+ const published = await apiUpload(await operatorConnection(options), path, zip, "application/zip");
1821
1851
  if (options.format === "json")
1822
1852
  return printJson(published);
1823
1853
  console.log(`${published.name} version ${published.version}`);
@@ -1829,7 +1859,7 @@ const commands = {
1829
1859
  async run(args, options) {
1830
1860
  if (args.length > 0)
1831
1861
  throw new CliError("Usage: skill list", exitCodes.usage);
1832
- const { skills } = await api(await operatorConnection(options.apiUrl), "/api/operator/skills");
1862
+ const { skills } = await api(await operatorConnection(options), "/api/operator/skills");
1833
1863
  if (options.format === "json")
1834
1864
  return printJson({ skills });
1835
1865
  if (skills.length === 0)
@@ -1869,7 +1899,7 @@ const commands = {
1869
1899
  async run(args, options) {
1870
1900
  if (args.length > 0)
1871
1901
  throw new CliError("Usage: worker list", exitCodes.usage);
1872
- const { workers } = await api(await resolveConnection(options.apiUrl), "/api/v1/workers");
1902
+ const { workers } = await api(await resolveConnection(options), "/api/v1/workers");
1873
1903
  if (options.format === "json")
1874
1904
  return printJson({ workers });
1875
1905
  if (workers.length === 0)
@@ -1893,7 +1923,7 @@ const commands = {
1893
1923
  async run(args, options) {
1894
1924
  if (args.length !== 1)
1895
1925
  throw new CliError("Usage: worker runs <hand-id | skill> [--limit <n>]", exitCodes.usage);
1896
- const connection = await resolveConnection(options.apiUrl);
1926
+ const connection = await resolveConnection(options);
1897
1927
  const id = await workerId(connection, args[0]);
1898
1928
  const query = options.limit === undefined ? "" : `?limit=${encodeURIComponent(options.limit)}`;
1899
1929
  const { runs } = await api(connection, `/api/v1/workers/${encodeURIComponent(id)}/runs${query}`);
@@ -1909,7 +1939,7 @@ const commands = {
1909
1939
  async run(args, options) {
1910
1940
  if (args.length !== 1)
1911
1941
  throw new CliError("Usage: worker run-now <hand-id | skill>", exitCodes.usage);
1912
- const connection = await resolveConnection(options.apiUrl);
1942
+ const connection = await resolveConnection(options);
1913
1943
  const id = await workerId(connection, args[0]);
1914
1944
  const queued = await api(connection, `/api/v1/workers/${encodeURIComponent(id)}/run-now`, {});
1915
1945
  if (options.format === "json")
@@ -1969,6 +1999,18 @@ const commands = {
1969
1999
  },
1970
2000
  },
1971
2001
  };
2002
+ function clientReason(choice) {
2003
+ switch (choice.source) {
2004
+ case "env":
2005
+ return `Using the key in ${envPrefix}_API_KEY, which beats --client, client files and saved logins.`;
2006
+ case "flag":
2007
+ return `Using client ${choice.client}, from --client.`;
2008
+ case "folder":
2009
+ return `Using client ${choice.client}, from the client file ${choice.path}.`;
2010
+ case "default":
2011
+ return `Using the default login (no --client flag and no ${CLIENT_FILE} file in this folder or above it).`;
2012
+ }
2013
+ }
1972
2014
  /** Runs `work` with the runner's settings from the environment; SIGINT or SIGTERM stops the run in hand, reported failed. */
1973
2015
  async function withRunner(options, work) {
1974
2016
  const token = process.env[RUNNER_TOKEN_ENV];
@@ -2008,7 +2050,7 @@ async function installLibrarySkill(ref, options) {
2008
2050
  if (options.codex && options.project)
2009
2051
  throw new CliError("--codex installs a skill for every Codex run, under CODEX_HOME; drop --project", exitCodes.usage);
2010
2052
  const query = ref.version === null ? "" : `?version=${ref.version}`;
2011
- const download = await api(await operatorConnection(options.apiUrl), `/api/operator/skills/${ref.name}/download${query}`);
2053
+ const download = await api(await operatorConnection(options), `/api/operator/skills/${ref.name}/download${query}`);
2012
2054
  const files = unpackSkill(download, await getSigned(download.url));
2013
2055
  const { skillsDir, above } = options.codex ? { skillsDir: codexSkillsDir(), above: [] } : claudeSkillsDir(options.project);
2014
2056
  const path = await installSkillFolder(skillsDir, download.name, files, above);
@@ -2071,16 +2113,21 @@ function money(value, currency) {
2071
2113
  /** A model that splits credit between ads gives fractional counts: show tenths at most, grouped as the server writes them. */
2072
2114
  const tenths = (n) => n.toLocaleString("en-US", { maximumFractionDigits: 1 });
2073
2115
  const counted = (n, one) => `${tenths(n)} ${tenths(n) === "1" ? one : `${one}s`}`;
2116
+ const SALE = { one: "sale", many: "sales" };
2117
+ const FIRST_LEAD = { one: "first lead", many: "first leads" };
2118
+ const winnersWords = (report) => report.goal === undefined ? { primary: SALE, fallback: FIRST_LEAD } : { primary: report.goal, fallback: report.fallbackGoal ?? null };
2119
+ const countOf = (n, { one, many }) => `${tenths(n)} ${tenths(n) === "1" ? one : many}`;
2074
2120
  /** An ad's line: name, SKU and campaign, its numbers, its tags, then why it is on the list. */
2075
- function winnersAdLines(ad, currency) {
2121
+ function winnersAdLines(ad, currency, words) {
2076
2122
  const n = ad.numbers;
2077
2123
  const onSales = ad.judgedOn === "sales";
2078
- const cost = onSales
2079
- ? n.costPerSale === null ? "no sales" : `${money(n.costPerSale, currency)} per sale`
2080
- : n.costPerSignup === null ? "no first leads" : `${money(n.costPerSignup, currency)} per first lead`;
2124
+ // Ads are judged on the fallback only when there is one; first leads stand in for a list the server never fills.
2125
+ const goal = onSales ? words.primary : (words.fallback ?? FIRST_LEAD);
2126
+ const [costPer, number] = onSales ? [n.costPerSale, n.sales] : [n.costPerSignup, n.signups];
2127
+ const cost = costPer === null ? `no ${goal.many}` : `${money(costPer, currency)} per ${goal.one}`;
2081
2128
  const name = [ad.name ?? `Ad ${ad.adId}`, ad.sku && `[${ad.sku}]`, ad.campaignName && `(${ad.campaignName})`].filter(Boolean).join(" ");
2082
2129
  const tags = Object.entries(ad.tags).map(([field, value]) => `${field}: ${value}`).join(", ");
2083
- const count = onSales ? n.sales !== null && counted(n.sales, "sale") : n.signups !== null && counted(n.signups, "first lead");
2130
+ const count = number !== null && countOf(number, goal);
2084
2131
  const facebook = n.facebookResults == null ? "" : `, Facebook counts ${tenths(n.facebookResults)}`;
2085
2132
  return `- ${name}: spent ${money(n.spend, currency)}, ${count ? `${count}, ` : ""}${cost}${facebook}${tags ? `. Tags: ${tags}` : ""}\n ${ad.pickedBecause}\n`;
2086
2133
  }
@@ -2097,14 +2144,14 @@ function winnersPageLines(page, currency) {
2097
2144
  const bought = onSales && n.salesPerVisit !== null ? `, ${Math.round(n.salesPerVisit * 1000) / 10}% of visitors bought` : "";
2098
2145
  return `- ${ad}${name}: ${counted(Math.round(n.visits), "visit")}, spent ${money(n.spend, currency)}, ${count ? `${count}, ` : ""}${cost}${bought}\n ${page.pickedBecause}\n`;
2099
2146
  }
2100
- function winnersWindowText(title, window) {
2147
+ function winnersWindowText(title, window, words) {
2101
2148
  const head = `\n## ${title}\n`;
2102
2149
  if (window.sections.length === 0)
2103
2150
  return `${head}\nNo Facebook ad spent in these dates.\n`;
2104
2151
  return (head +
2105
2152
  window.sections
2106
2153
  .map((s) => {
2107
- const list = (heading, ads) => `\n${heading}\n${ads.length === 0 ? "- none\n" : ads.map((ad) => winnersAdLines(ad, s.currency)).join("")}`;
2154
+ const list = (heading, ads) => `\n${heading}\n${ads.length === 0 ? "- none\n" : ads.map((ad) => winnersAdLines(ad, s.currency, words)).join("")}`;
2108
2155
  const places = (heading, rows) => `\n${heading}\n${rows.length === 0 ? "- none\n" : rows.map((row) => winnersPageLines(row, s.currency)).join("")}`;
2109
2156
  const placeLists = (what, lists) => places(`${what}: winners`, lists.winners) +
2110
2157
  places(`${what}: flops`, lists.flops) +
@@ -2115,15 +2162,18 @@ function winnersWindowText(title, window) {
2115
2162
  ? ""
2116
2163
  : placeLists("Landing pages", s.pages) +
2117
2164
  (any(s.pairs) ? placeLists("Ad and page pairs", s.pairs) : "\nAd and page pairs: none to compare. No ad sent people to two pages, and no page got people from two ads, with enough visits.\n");
2165
+ const { primary, fallback } = words;
2118
2166
  const account = s.salesSource === "not_connected"
2119
- ? "sales are not connected"
2167
+ ? `${primary.many} are not connected`
2120
2168
  : s.accountCostPerSale === null
2121
- ? "no sale credited to a Facebook ad in these dates"
2122
- : `the account paid ${money(s.accountCostPerSale, s.currency)} per sale`;
2123
- const tied = s.adSales === null || !s.facebookSales ? "" : ` ${tenths(s.adSales)} of ${counted(s.facebookSales, "Facebook sale")} could be tied to a specific ad.`;
2124
- const tests = s.tests.map((t) => `- Test ${t.testId}${t.hypothesisId ? ` (idea ${t.hypothesisId})` : ""}: ${counted(t.ads.length, "ad")}, spent ${money(t.spend, s.currency)}, ${counted(t.sales, "sale")}` +
2125
- `${t.costPerSale === null ? "" : `, ${money(t.costPerSale, s.currency)} per sale`}\n`);
2126
- return (`\nIn ${s.currency}: ${account}.${tied} ${s.adsJudgedOnSales} judged on sales, ${s.adsJudgedOnSignups} judged on first leads.\n` +
2169
+ ? `no ${primary.one} credited to a Facebook ad in these dates`
2170
+ : `the account paid ${money(s.accountCostPerSale, s.currency)} per ${primary.one}`;
2171
+ const facebookWords = { one: `Facebook ${primary.one}`, many: `Facebook ${primary.many}` };
2172
+ const tied = s.adSales === null || !s.facebookSales ? "" : ` ${tenths(s.adSales)} of ${countOf(s.facebookSales, facebookWords)} could be tied to a specific ad.`;
2173
+ const tests = s.tests.map((t) => `- Test ${t.testId}${t.hypothesisId ? ` (idea ${t.hypothesisId})` : ""}: ${counted(t.ads.length, "ad")}, spent ${money(t.spend, s.currency)}, ${countOf(t.sales, primary)}` +
2174
+ `${t.costPerSale === null ? "" : `, ${money(t.costPerSale, s.currency)} per ${primary.one}`}\n`);
2175
+ const onFallback = fallback === null ? "" : `, ${s.adsJudgedOnSignups} judged on ${fallback.many}`;
2176
+ return (`\nIn ${s.currency}: ${account}.${tied} ${s.adsJudgedOnSales} judged on ${primary.many}${onFallback}.\n` +
2127
2177
  s.notes.map((note) => `${note}\n`).join("") +
2128
2178
  list("Winners", s.winners) +
2129
2179
  list("Flops", s.flops) +
@@ -2173,18 +2223,37 @@ function winnersSendText(sent) {
2173
2223
  lines.push("Left out:", ...sent.skipped.map((s) => ` ${s.adId}: ${s.reason}`));
2174
2224
  return `${lines.join("\n")}\n`;
2175
2225
  }
2176
- /** How an older server, which sent no rule, credited a sale. */
2226
+ /** How an older server, which sent no rule and judged only on sales, credited a sale. */
2177
2227
  const FIRST_TOUCH_SALE_RULE = "Each sale goes to the Facebook ad that first brought the buyer in; payments typed into GoHighLevel by hand never count toward an ad.";
2178
2228
  /**
2179
- * The winners report as plain Markdown: what a sale means, then the dates asked for and, unless they are the same dates,
2180
- * the last 30 days, each currency apart.
2229
+ * The winners report as plain Markdown: which goal the ads are judged on and what one means, then the dates asked for and,
2230
+ * unless they are the same dates, the last 30 days, each currency apart.
2181
2231
  */
2182
2232
  function winnersText(report) {
2183
2233
  const { week, last30Days } = report;
2234
+ const words = winnersWords(report);
2184
2235
  return (`# Winners and flops for ${report.workspace}${report.format ? `, ${report.format} ads only` : ""}\n\n` +
2185
- `A sale means ${report.saleMeans}. ${report.saleRule ?? FIRST_TOUCH_SALE_RULE}\n` +
2186
- winnersWindowText(winnersDatesTitle(report.window), week) +
2187
- (week.from === last30Days.from && week.to === last30Days.to ? "" : winnersWindowText(`Last 30 days, ${last30Days.from} to ${last30Days.to}`, last30Days)));
2236
+ (report.goalSentence ? `${report.goalSentence}\n` : "") +
2237
+ `A ${words.primary.one} means ${report.saleMeans}. ${report.saleRule ?? FIRST_TOUCH_SALE_RULE}\n` +
2238
+ winnersWindowText(winnersDatesTitle(report.window), week, words) +
2239
+ (week.from === last30Days.from && week.to === last30Days.to ? "" : winnersWindowText(`Last 30 days, ${last30Days.from} to ${last30Days.to}`, last30Days, words)));
2240
+ }
2241
+ function printWinnersGoals(goals, format) {
2242
+ if (format === "json")
2243
+ return printJson(goals);
2244
+ process.stdout.write(winnersGoalsText(goals));
2245
+ }
2246
+ /** The goals in order, each with whether its data is connected, then which one winners judges on now and why. */
2247
+ function winnersGoalsText({ goals, isDefault, chosen }) {
2248
+ const rows = goals.map((g, i) => ({
2249
+ order: String(i + 1),
2250
+ goal: g.source === "first_touch" || g.source === "meta" ? g.kind : `${g.kind}:${g.source}`,
2251
+ counts: g.label,
2252
+ data: g.measurable ? "connected" : "not connected",
2253
+ }));
2254
+ return (`${renderTable(["order", "goal", "counts", "data"], rows)}\n\n` +
2255
+ `${isDefault ? "This is the default list; no goals have been set for this client." : "These goals were set for this client."}\n` +
2256
+ `${chosen.sentence}\n`);
2188
2257
  }
2189
2258
  function reportQuery(options, extra) {
2190
2259
  const query = new URLSearchParams();
@@ -2458,6 +2527,8 @@ ${Object.values(commands)
2458
2527
  Options:
2459
2528
  --format table|json output format (default: table)
2460
2529
  --api-url <url> API base URL
2530
+ --client <name> use that client's saved login (login --client <name> saves it); beats a ${CLIENT_FILE} file, which
2531
+ names the client for its folder and every folder below it; ${envPrefix}_API_KEY beats both
2461
2532
  --wait <seconds> how long tools run and tools get wait for a run (default: ${DEFAULT_WAIT_SECONDS}; 0 returns at once)
2462
2533
  --agent <kind> which agent the agent commands use: meta_ads (default) or p3_refresh; in a scheduled hand's run, skill
2463
2534
  (the run's skill's agent) is the default
@@ -2549,6 +2620,12 @@ ${envPrefix}_OPERATOR_TOKEN instead of an API key, as do worker promote and work
2549
2620
  version a scheduled hand is trying the one in use, there and on every hand of the skill on an older version; worker
2550
2621
  drop stops trying it.
2551
2622
 
2623
+ winners judges a client's Facebook ads on its goals, tried in order: the first goal whose data is connected is the one
2624
+ used, and ads too new to judge on it are judged on the next connected goal of another kind. sales means first-touch
2625
+ sales from every connected payment source; to judge on Stripe sales, connect Stripe, then put sales first.
2626
+ sales:triple_whale means the orders Triple Whale credits to each ad. Meta's applications (submit_application_website)
2627
+ are judged through meta_results once the Meta source's result types are set to them.
2628
+
2552
2629
  runner and runner once take the runner token in ${RUNNER_TOKEN_ENV}. ${HARNESS_COMMAND_ENV}, if set, names a program
2553
2630
  the runner starts instead of Claude Code or Codex.
2554
2631
 
@@ -2562,6 +2639,7 @@ function parseOptions(argv) {
2562
2639
  format: { type: "string", default: "table" },
2563
2640
  json: { type: "boolean", default: false },
2564
2641
  "api-url": { type: "string" },
2642
+ client: { type: "string" },
2565
2643
  file: { type: "string", multiple: true },
2566
2644
  project: { type: "boolean", default: false },
2567
2645
  input: { type: "string" },
@@ -2670,6 +2748,7 @@ function parseOptions(argv) {
2670
2748
  options: {
2671
2749
  format: values.json ? "json" : values.format,
2672
2750
  apiUrl: values["api-url"],
2751
+ client: values.client,
2673
2752
  file: values.file?.at(-1),
2674
2753
  files: values.file ?? [],
2675
2754
  project: values.project,
@@ -2765,7 +2844,7 @@ function parseOptions(argv) {
2765
2844
  async function changeCandidate(change, args, options) {
2766
2845
  if (args.length !== 1 || !options.workspace)
2767
2846
  throw new CliError(`Usage: worker ${change} <skill> --workspace <slug>`, exitCodes.usage);
2768
- return api(await operatorConnection(options.apiUrl), `/api/operator/workers/${change}`, { workspace: options.workspace, skill: args[0] });
2847
+ return api(await operatorConnection(options), `/api/operator/workers/${change}`, { workspace: options.workspace, skill: args[0] });
2769
2848
  }
2770
2849
  function findCommand(positionals) {
2771
2850
  const three = positionals.slice(0, 3).join(" ");
@@ -3514,7 +3593,7 @@ function printIntakeToken({ endpoint, address, token, link, subscription }, form
3514
3593
  console.error(`The token for ${endpoint.label} is shown only now. Rotate it if it is lost.`);
3515
3594
  }
3516
3595
  async function patchIntake(id, body, options, done) {
3517
- const { endpoint } = await api(await resolveConnection(options.apiUrl), intakePath(id), body, "PATCH");
3596
+ const { endpoint } = await api(await resolveConnection(options), intakePath(id), body, "PATCH");
3518
3597
  if (options.format === "json")
3519
3598
  return printJson({ endpoint });
3520
3599
  console.log(done(endpoint));