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