@wrangled/cli 0.3.52 → 0.3.56
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/api.js +14 -0
- package/dist/enrich.js +101 -0
- package/dist/guide.js +43 -2
- package/dist/main.js +531 -23
- package/dist/tool-usage.js +35 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -21,13 +21,27 @@ export class UnreadableCredentialsError extends CliError {
|
|
|
21
21
|
* in src/lib/knowledge/tags.ts. format's flag is --ad-format, because --format picks the output.
|
|
22
22
|
*/
|
|
23
23
|
export const entryTagFlags = { angle: "angle", hook_kind: "hook-kind", format: "ad-format", awareness: "awareness" };
|
|
24
|
+
/** Mirrors PUSH_MAX_DEFAULT, PUSH_MAX_CEILING and PUSH_CSV_MAX_BYTES in src/lib/outbound/model.ts. */
|
|
25
|
+
export const OUTBOUND_PUSH_MAX = { default: 5000, ceiling: 50_000, csvBytes: 4 * 1024 * 1024 };
|
|
24
26
|
/** The most ads or ad sets one meta ads|adset pause|resume sends; the server refuses more. */
|
|
25
27
|
export const META_IDS_MAX = 10;
|
|
28
|
+
/** The most ads or ad groups one google-ads ads|adgroup pause|resume sends; the server refuses more. */
|
|
29
|
+
export const GOOGLE_ADS_IDS_MAX = 10;
|
|
26
30
|
/** The days before a sale in which a click or reply helps it, as the app counts them (MESSAGE_CREDIT_DAYS). */
|
|
27
31
|
export const MESSAGE_CREDIT_DAYS = 7;
|
|
28
32
|
/** Copies of the server's IMPORT_LIMITS and import name rule; src/lib/cli-consistency.test.ts keeps them in step. */
|
|
29
33
|
export const IMPORT_LIMITS = { bytes: 50 * 1024 * 1024, rows: 1_000_000, columns: 500 };
|
|
30
34
|
export const IMPORT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
|
|
35
|
+
/** Copies of the server's AGENT_TABLE_LIMITS and agent table name rule; src/lib/cli-consistency.test.ts keeps them in step. */
|
|
36
|
+
export const AGENT_TABLE_LIMITS = {
|
|
37
|
+
tables: 50,
|
|
38
|
+
rowsPerTable: 1_000_000,
|
|
39
|
+
bytes: 500 * 1024 * 1024,
|
|
40
|
+
batchRows: 10_000,
|
|
41
|
+
batchBytes: 10 * 1024 * 1024,
|
|
42
|
+
columns: 500,
|
|
43
|
+
};
|
|
44
|
+
export const AGENT_TABLE_NAME_PATTERN = /^[a-z0-9_]{1,48}$/;
|
|
31
45
|
/** `configHome` defaults to $XDG_CONFIG_HOME, or ~/.config. */
|
|
32
46
|
export function credentialsPath(configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")) {
|
|
33
47
|
return join(configHome, brand.slug, "credentials.json");
|
package/dist/enrich.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { CliError, exitCodes } from "./api.js";
|
|
2
|
+
/** The most people one find-email request takes; `enrich email` sends a longer file in batches of this many. */
|
|
3
|
+
export const FIND_EMAIL_PEOPLE_MAX = 25;
|
|
4
|
+
export const ENRICH_EMAIL_USAGE = "enrich email --file <people.csv | people.json> [--steps <tool,tool>]";
|
|
5
|
+
const COLUMNS = ["firstName", "lastName", "domain", "companyName", "linkedinUrl"];
|
|
6
|
+
const usage = (problem) => new CliError(`${problem}\nUsage: ${ENRICH_EMAIL_USAGE}`, exitCodes.usage);
|
|
7
|
+
/** RFC 4180 rows: quoted fields may hold commas, doubled quotes and line breaks. */
|
|
8
|
+
function csvRows(text) {
|
|
9
|
+
const rows = [];
|
|
10
|
+
let row = [];
|
|
11
|
+
let field = "";
|
|
12
|
+
let quoted = false;
|
|
13
|
+
for (let i = 0; i < text.length; i++) {
|
|
14
|
+
const char = text[i];
|
|
15
|
+
if (quoted) {
|
|
16
|
+
if (char === '"' && text[i + 1] === '"') {
|
|
17
|
+
field += '"';
|
|
18
|
+
i++;
|
|
19
|
+
}
|
|
20
|
+
else if (char === '"')
|
|
21
|
+
quoted = false;
|
|
22
|
+
else
|
|
23
|
+
field += char;
|
|
24
|
+
}
|
|
25
|
+
else if (char === '"')
|
|
26
|
+
quoted = true;
|
|
27
|
+
else if (char === ",") {
|
|
28
|
+
row.push(field);
|
|
29
|
+
field = "";
|
|
30
|
+
}
|
|
31
|
+
else if (char === "\n" || char === "\r") {
|
|
32
|
+
if (char === "\r" && text[i + 1] === "\n")
|
|
33
|
+
i++;
|
|
34
|
+
rows.push([...row, field]);
|
|
35
|
+
row = [];
|
|
36
|
+
field = "";
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
field += char;
|
|
40
|
+
}
|
|
41
|
+
if (quoted)
|
|
42
|
+
throw usage("The CSV file has a quote that is never closed.");
|
|
43
|
+
if (field !== "" || row.length > 0)
|
|
44
|
+
rows.push([...row, field]);
|
|
45
|
+
return rows.filter((cells) => cells.some((cell) => cell.trim() !== ""));
|
|
46
|
+
}
|
|
47
|
+
function fromCsv(text) {
|
|
48
|
+
const [header, ...rows] = csvRows(text.replace(/^/, ""));
|
|
49
|
+
if (!header)
|
|
50
|
+
throw usage("The CSV file is empty.");
|
|
51
|
+
const names = header.map((name) => name.trim());
|
|
52
|
+
const unknown = names.filter((name) => !COLUMNS.includes(name));
|
|
53
|
+
if (unknown.length > 0)
|
|
54
|
+
throw usage(`Unknown CSV column ${unknown.map((n) => JSON.stringify(n)).join(", ")}; the columns are ${COLUMNS.join(", ")}.`);
|
|
55
|
+
return rows.map((cells) => Object.fromEntries(names.flatMap((name, i) => (cells[i]?.trim() ? [[name, cells[i].trim()]] : []))));
|
|
56
|
+
}
|
|
57
|
+
function fromJson(text) {
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(text);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw usage("The JSON file is not valid JSON.");
|
|
64
|
+
}
|
|
65
|
+
const people = Array.isArray(parsed) ? parsed : parsed?.people;
|
|
66
|
+
if (!Array.isArray(people))
|
|
67
|
+
throw usage('The JSON file must be a list of people, or {"people": [...]}.');
|
|
68
|
+
return people;
|
|
69
|
+
}
|
|
70
|
+
/** The people in a `.json` file (a list, or {people: [...]}) or a CSV file whose header names the columns. The server checks each person. */
|
|
71
|
+
export function parsePeople(text, path) {
|
|
72
|
+
const people = path.toLowerCase().endsWith(".json") ? fromJson(text) : fromCsv(text);
|
|
73
|
+
if (people.length === 0)
|
|
74
|
+
throw usage("The file lists no people.");
|
|
75
|
+
return people;
|
|
76
|
+
}
|
|
77
|
+
/** --steps as the list of tool ids, or undefined for every step. */
|
|
78
|
+
export function parseSteps(flag) {
|
|
79
|
+
if (flag === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
const steps = flag.split(",").map((step) => step.trim()).filter(Boolean);
|
|
82
|
+
if (steps.length === 0)
|
|
83
|
+
throw usage("--steps names no tools.");
|
|
84
|
+
return steps;
|
|
85
|
+
}
|
|
86
|
+
export function batches(items, size = FIND_EMAIL_PEOPLE_MAX) {
|
|
87
|
+
return Array.from({ length: Math.ceil(items.length / size) }, (_, i) => items.slice(i * size, (i + 1) * size));
|
|
88
|
+
}
|
|
89
|
+
/** One printed row per person. */
|
|
90
|
+
export function answerRow(answer) {
|
|
91
|
+
const { person } = answer;
|
|
92
|
+
return {
|
|
93
|
+
name: [person.firstName, person.lastName].filter(Boolean).join(" "),
|
|
94
|
+
domain: person.domain ?? person.companyName ?? person.linkedinUrl ?? "",
|
|
95
|
+
email: answer.email ?? "",
|
|
96
|
+
status: answer.status,
|
|
97
|
+
"found by": answer.foundBy ?? "",
|
|
98
|
+
cost: `${Math.round(answer.costCents * 100) / 100}¢`,
|
|
99
|
+
"run ids": answer.steps.flatMap((step) => (step.runId ? [step.runId] : [])).join(" "),
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/guide.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { FIND_EMAIL_PEOPLE_MAX } from "./enrich.js";
|
|
2
|
+
import { AGENT_TABLE_LIMITS, IMPORT_LIMITS, MESSAGE_CREDIT_DAYS } from "./api.js";
|
|
2
3
|
import { brand, envPrefix } from "./brand.js";
|
|
3
4
|
import { BACK_TAG_SKILL_NAME, CRO_SKILL_NAME, GRAMMAR_SKILL_NAME, LEARN_NAMES_SKILL_NAME, LESSONS_SKILL_NAME, MAP_OFFERS_SKILL_NAME, PLAYBOOK_CHOICES, STORY_SKILL_NAME, WEEKLY_LOOP_SKILL_NAME } from "./playbooks.js";
|
|
4
5
|
import { PULL_STATE_FILE } from "./pull.js";
|
|
@@ -92,7 +93,7 @@ To find content, such as "the 10 emails with the best click rate", "our 5 best v
|
|
|
92
93
|
|
|
93
94
|
- \`--text "<words>"\` finds words in the current version's subject, headline and body, or in a video's or call recording's transcript. Words are stemmed ("guarantee" finds "guaranteed"); use "quotes" for a phrase and -word to leave a word out.
|
|
94
95
|
- \`--mode meaning\` finds content that means something close to \`--text\`, even in other words: \`--text "saves time"\` also finds "done in five minutes". Results come nearest first, each with its distance (0 is the same meaning) and the closest passage as its words. It takes no \`--sort\` or \`--order\`, and finds only content embedded so far (each hour, new versions and transcripts are embedded).
|
|
95
|
-
- \`--kind\` (ad, email, sms, landing_page, video, social_post, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, activecampaign, mailchimp, hubspot, kit, instantly, kajabi, omnisend, google_ads, tiktok, microsoft_ads, pinterest, web, youtube, instagram, facebook_page, callrail, zoom, fathom, google_drive, ad_library, workspace, upload) and \`--origin\` (discovered or made) narrow it. \`--from\` and \`--to\` keep content live on those days: from its first day with numbers (or the day the library first saw it) to its last.
|
|
96
|
+
- \`--kind\` (ad, email, sms, landing_page, video, social_post, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, activecampaign, mailchimp, hubspot, kit, instantly, kajabi, omnisend, google_ads, tiktok, microsoft_ads, pinterest, linkedin, web, youtube, instagram, facebook_page, callrail, zoom, fathom, google_drive, ad_library, workspace, upload) and \`--origin\` (discovered or made) narrow it. \`--from\` and \`--to\` keep content live on those days: from its first day with numbers (or the day the library first saw it) to its last.
|
|
96
97
|
- \`--sort\` is \`recent\` (the default, newest first) or any number in the \`content_scorecard\` view, such as \`click_rate\`, \`open_rate\`, \`roas\`, \`spend\` or their \`_30d\` twins for the last 30 days. Content with money in several currencies has a number per currency and sorts by its best. Content without that number comes last. \`--order asc\` turns it around; \`--limit\` is 1 to 50 (default 10).
|
|
97
98
|
- Each result has the item's id or SKU, its words, how many files it has, whether it has a transcript, and its scorecard numbers. \`${cli} content show <sku | item-id>\` prints one item in full: its words, how many versions it has, its transcript and every number; \`--format json\` adds every version, and links to its files.
|
|
98
99
|
- To add marketing that lives somewhere no source connects to, such as a sales video, a webinar recording, a script or an email from another tool, run \`${cli} content capture --kind <kind> --name <name>\` with \`--body\`, \`--file\`, \`--source\` (where it came from) and \`--url\`. It is kept as found, with no SKU, so it is never approved or published; \`--platform upload\` finds these. Videos and audio are transcribed on their own; give \`--transcript @file\` when you already have one, so none is paid for.
|
|
@@ -129,6 +130,10 @@ For "how is the funnel doing?", "which campaigns are working?" and "where does t
|
|
|
129
130
|
- Money is never converted or added across currencies: there is one row per currency, and one total per currency. In \`${cli} funnel\`, leads and calls are counted for the whole period and repeat on every currency row, so never add them across its rows. In \`${cli} campaigns\`, each campaign's leads and calls sit on the row of the currency it spends in (or a blank-currency row when it has no spend), so they do add up across rows and totals. Ratios come from the summed numbers, not averages. An empty cell means the source is not connected, or a ratio would divide by 0.
|
|
130
131
|
- To go deeper, query the views behind them: \`daily_funnel\`, \`campaign_results_daily\`, \`daily_spend\` and \`daily_sales\`. Their descriptions in \`${cli} warehouse schema\` say what each counts.
|
|
131
132
|
|
|
133
|
+
## Page-two keywords
|
|
134
|
+
|
|
135
|
+
\`${cli} seo page-two\` lists the Google searches where the client's site shows up, but only around positions 11 to 20: the searches closest to page one. It reads Google Search Console's \`gsc_search_daily\` over the last 90 days ending on the newest day it has, and keeps a search when its average position, weighted by impressions, is 11 to 20 and it was shown at least 100 times. Its click rate is its clicks over its impressions, never an average of daily rates. Each row is one property and search, with the page shown most for it; \`--by query-page\` gives one row per page instead. \`--site <site_url>\` reads one property (\`sc-domain:example.com\` or \`https://www.example.com/\`), \`--days <n>\` (at most 486), \`--from <n>\` and \`--to <n>\` the positions, \`--min-impressions <n>\`, and \`--limit <n>\` (1 to 500, default 50). Without Search Console data it says so instead of printing an empty list.
|
|
136
|
+
|
|
132
137
|
## Webhook addresses
|
|
133
138
|
|
|
134
139
|
A webhook address lets a tool such as a form or webinar platform push events into the warehouse's \`raw_events\` table. \`${cli} intake list\` shows each address with whether it is on, how many days it keeps events, the events of the last 24 hours, the last event's time, and the rows it holds. \`${cli} intake create <label>\` makes one and prints its link, address and token. The link is the one URL to paste into the sending tool: the address with the token on the end, or for Mailgun the bare address. Add \`--kind ghl_workflow\` for a GoHighLevel workflow's Webhook step: its events also land, typed, in \`ghl_events\`. Add \`--kind aloware\` for Aloware's call and text webhooks: they also land, typed, in \`aloware_calls\` and \`aloware_texts\`. Add \`--kind webinarfuel\` or \`--kind stealth_seminar\` for a webinar tool: its sign-ups, attendance, replays and offer clicks land in \`webinar_events\`. Add \`--kind mailgun --signing-key-stdin\`, with Mailgun's HTTP webhook signing key piped on stdin, for a Mailgun account's webhooks: each event's signature is checked and alone proves the sender, so Mailgun posts to the address itself with no token, and its events also land, typed, in \`mailgun_events\`. When the account's key changes, pipe the new one to \`${cli} intake signing-key <id> --signing-key-stdin\`; deleting the workspace's last Mailgun address deletes the key. Add \`--kind thrivecart_events\` for a ThriveCart account's webhook: affiliate commissions earned, taken back and paid out also land, typed, in \`thrivecart_affiliate_commissions\`, where they stay after their events pass the retention, and \`daily_spend\` counts them. Add \`--kind roezan\` once the workspace's Roezan source is connected: the server uses the source's key to ask Roezan to send each new broadcast to the link (\`subscription\` in the answer says whether that worked; if not, add the link in Roezan's Webhooks tab), each broadcast an event names lands in \`roezan_broadcast_events\`, and the Roezan source reads it on its next hourly sync. The link and token are shown only then and after \`${cli} intake rotate <id>\`. Paste the link into the sending tool, or, when the tool can set a header, give it the address and \`Authorization: Bearer <token>\` instead, which keeps the token out of request logs. Never paste either anywhere else. Events are kept for 90 days unless \`--retention-days\` or \`${cli} intake retention <id> <days>\` says otherwise (1 to 3650). \`${cli} intake delete <id> --yes\` deletes the address and every event it saved. A website visit tag shows in \`${cli} intake list\` as an address of kind \`visit_tag\`, and each page view it records lands in \`site_visits\` (host, path, referrer host, UTM tags, click ids, country, device type, a bot flag and a random visitor id). \`${cli} intake create\` cannot make one: a tag has no token and needs its list of allowed sites, so an operator makes it with the server's \`npm run visit-tag:create\` script.
|
|
@@ -151,6 +156,33 @@ A webhook address lets a tool such as a form or webinar platform push events int
|
|
|
151
156
|
- Limits: ${IMPORT_LIMITS.bytes / (1024 * 1024)} MB, ${IMPORT_LIMITS.rows.toLocaleString("en-US")} rows and ${IMPORT_LIMITS.columns} columns per file. A file over 4.5 MB, more than the hosted app takes in one request, goes to the workspace's private file storage first and is imported from there, then deleted; the command does this by itself.
|
|
152
157
|
- The output lists the table, its row count, and each column with its type and original header. \`--format json\` prints \`{ sourceId, table, rows, columns: [{ name, type, sourceHeader }], importedAt }\`.
|
|
153
158
|
|
|
159
|
+
## Your own tables
|
|
160
|
+
|
|
161
|
+
An agent can keep its own lists in the warehouse across runs, such as keywords, competitors or a lead list, and join them with the client's real data in \`${cli} warehouse query\`.
|
|
162
|
+
|
|
163
|
+
- \`${cli} tables create keywords --columns "keyword text, volume int, intent text" [--description <text>]\` makes the empty table \`agent_keywords\`. The name is 1 to 48 lowercase letters, digits and underscores. Column types are \`text\`, \`int\`, \`bigint\`, \`numeric\`, \`boolean\`, \`date\`, \`timestamptz\` and \`jsonb\`. Every table also gets \`_row_id\`, \`_created_at\` and \`_created_by\` (\`api_key:<id>\` or \`worker_run:<id>\`).
|
|
164
|
+
- Emails and phone numbers may live only in these tables. Mark the columns that hold them with \`--personal email,phone\`; a column whose name says email, phone or mobile is marked anyway. The Data page never shows a personal column.
|
|
165
|
+
- \`${cli} tables insert keywords --file rows.json\` adds rows from a JSON array of objects keyed by column name, or one object per line in a \`.ndjson\` file. A column a row leaves out is NULL; a column the table lacks is refused. The command sends ${AGENT_TABLE_LIMITS.batchRows.toLocaleString("en-US")} rows or about 4 MB at a time; each batch goes in whole or not at all.
|
|
166
|
+
- \`${cli} tables import keywords --file list.csv [--mode append|replace]\` adds a CSV file's rows, read the way \`${cli} import csv\` reads them, or with \`replace\` swaps them in for every row the table had. Importing into a table that does not exist yet makes it from the file's headers and types; add \`--personal contact\` to mark the columns holding emails or phone numbers, since only a new table takes them. A header the table has no column for is refused.
|
|
167
|
+
- \`${cli} tables delete-rows keywords --where '{"intent": "local"}'\` deletes the rows matching every pair; \`{"_row_id": 12}\` deletes one row. \`${cli} tables drop keywords --yes\` drops the table.
|
|
168
|
+
- \`${cli} tables list\` shows each table with its rows and size. Limits per client: ${AGENT_TABLE_LIMITS.tables} tables, ${AGENT_TABLE_LIMITS.rowsPerTable.toLocaleString("en-US")} rows per table and ${AGENT_TABLE_LIMITS.bytes / (1024 * 1024)} MB in all. A write over a limit is refused with \`413\` and the current size.
|
|
169
|
+
- Over MCP: \`list_tables\`, \`create_table\`, \`insert_rows\` and \`drop_table\`. Over REST: \`GET\`/\`POST /api/v1/tables\`, \`GET\`/\`DELETE /api/v1/tables/<name>\`, \`POST\`/\`DELETE /api/v1/tables/<name>/rows\` and \`POST /api/v1/tables/<name>/import?mode=append|replace\`.
|
|
170
|
+
|
|
171
|
+
## Finding work emails
|
|
172
|
+
|
|
173
|
+
\`${cli} enrich email --file people.csv\` finds one verified work email per person. The CSV's header names its columns: \`firstName,lastName,domain,companyName,linkedinUrl\`; each person needs both names and a domain, company name or LinkedIn link. A \`.json\` file is a list of the same objects. It tries LeadMagic, Prospeo, a MillionVerifier guess and Apollo, cheapest first, and stops at the first verified address, so it never pays a second provider for someone already found. A provider the client has no key for is skipped. It prints one row per person: name, domain, email, status (\`valid\`, \`catch_all\` for an unverified address on a domain that accepts every address, \`not_found\`, \`cap_reached\` when a hand's tool limit refused a step, or \`running\` when an earlier call's lookup is still going and the same file should be sent again later), the tool that found it, the cost and the tool runs. \`--steps leadmagic:find-email,prospeo:find-email\` tries only those tools. Sending the same people again replays the lookups already run without paying again. Over MCP: \`find_email\`. Over REST: \`POST /api/v1/enrich/email\` with \`{"people": [...], "steps"?: [...]}\`, up to ${FIND_EMAIL_PEOPLE_MAX} people a request; the CLI sends a longer file in batches of that many.
|
|
174
|
+
|
|
175
|
+
## Pushing leads to Instantly
|
|
176
|
+
|
|
177
|
+
\`${cli} outbound push --to instantly:<campaign-id> --csv leads.csv\` loads a lead list into an Instantly campaign without emailing anyone twice. \`--table agent_hosts\` (or an \`import_\` table) pushes a table's rows instead of a file.
|
|
178
|
+
|
|
179
|
+
- The list needs an \`email\` column. \`first_name\`, \`last_name\`, \`company_name\`, \`website\` and \`phone\` fill Instantly's fields; every other column with a value becomes a custom variable.
|
|
180
|
+
- Skipped before anything is sent: rows without a valid email, repeats in the list, and emails already among the client's synced Instantly leads (any campaign) or added (or found already there) by an earlier push. Instantly then skips anyone already in the campaign or its workspace.
|
|
181
|
+
- Run it with \`--dry-run\` first: it prints the same counts and sends and saves nothing. More rows than \`--max\` (default 5000, at most 50000) is refused, never cut short.
|
|
182
|
+
- Each real push prints an idempotency key; sending the same command again with \`--idempotency-key <key>\` answers the first push instead of pushing again. If the answer says it is still sending, run the same command with the same key again in a few minutes; a push stalled for 10 minutes is resumed where it stopped.
|
|
183
|
+
- It uses the client's stored \`instantly_write\` key (\`${cli} secrets set instantly_write\`), else the Instantly source's key. If Instantly refuses that key, store one that can add leads.
|
|
184
|
+
- Only counts and each email's hash are kept, never the leads. Over MCP: \`push_outbound_leads\`. Over REST: \`POST /api/v1/outbound/pushes\` and \`GET /api/v1/outbound/pushes/<id>\`.
|
|
185
|
+
|
|
154
186
|
## Making ads: tools and the creative ledger
|
|
155
187
|
|
|
156
188
|
1. \`${cli} tools list\` shows each tool id (\`provider:tool\`, such as \`kie:nano-banana\`), whether it runs sync or async, and what it makes. \`--format json\` adds each tool's description, cost, and input JSON Schema.
|
|
@@ -187,6 +219,8 @@ The \`meta_ads\` publisher puts an approved ad on Facebook, always paused, in an
|
|
|
187
219
|
|
|
188
220
|
\`${cli} meta ad create --account act_<id> --adset <id> --file ad.png --text "<primary text>" --headline "<headline>" --link <https url>\` does all of that in one step: it saves the files and words as an ad with a new SKU, approves it and publishes it with \`meta_ads\` into the ad set, then prints the SKU, the publication, the Meta ad id and its status. Repeat \`--file\` for more pictures; a big file is uploaded first. \`--cta SHOP_NOW\` sets the button and \`--name\` names the ad (the headline otherwise). The same rules apply and are all checked before anything is saved, so a refusal leaves nothing behind. Your own key sends to Facebook without \`--run\`, but the account and campaign must also be on the workspace's control list; from a hand's run in practice mode it is recorded and nothing is sent. The ad starts paused; \`--status active --reason "<why>"\` switches it on 15 seconds after it is made, as a change in the change log that \`${cli} meta changes undo <change-id>\` reverses, and needs room under the day's limit of on/off changes. Without \`--idempotency-key\` the key comes from the files and the flags, so the same command run twice makes one ad.
|
|
189
221
|
|
|
222
|
+
Google Ads has the same controls. \`${cli} google-ads ads pause <adgroup-id>~<ad-id> --reason "<why>"\` (ids from \`gads_ads\`) and \`${cli} google-ads adgroup pause|resume <adgroup-id>\` turn ads and ad groups off or on, and \`${cli} google-ads campaign budget <campaign-id> --percent 10 --reason "<why>"\` (or \`--daily 60.50\`, in the account's currency) changes a campaign's daily budget. The account and campaign must be on the workspace's Google Ads control list, which an Admin sets on the Settings page, and the same daily limits, largest budget step, total daily budget limit and approval rule apply; a shared budget is never changed. With approval on (the default), a hand proposes \`gads_budget_change\` and the change goes out once a person approves it. Only your own key writes to Google Ads: no hand does yet. \`${cli} google-ads changes\` lists every change with who asked and why, \`${cli} google-ads changes undo <id>\` puts one back, and \`${cli} google-ads changes reconcile <id>\` settles one whose answer was lost.
|
|
223
|
+
|
|
190
224
|
\`${cli} meta adset publish --plan '<json>' --idempotency-key <key> --run <run-id>\` makes one new Meta ad set, always paused, for ads to go into. A copy, \`{"kind": "copy", "accountId": "act_<id>", "campaignId": "<id>", "sourceAdsetId": "<id>", "name": "<new name>"}\`, copies a proven ad set without its ads; the source must be on the hand's list of ad sets that may be copied. A new one from settings is \`{"kind": "create", "accountId": ..., "campaignId": ..., "name": ..., "settings": {"optimizationGoal": "...", "billingEvent": "...", "targeting": {...}, "dailyBudget": 5000}}\`, with the budget in the currency's smallest unit (5000 is 50.00 USD) and left out when the campaign holds it. The account and campaign must be on the publish list, the name unused in the campaign, and the day under the limit of new ad sets (3 unless an operator sets another). Ad sets and ads share one account's turn: one unfinished send at a time, and at least 15 seconds apart. ${brand.name} reads the new ad set back and checks it: paused, in the right campaign, with the right name, and for a copy, targeting equal to the source's. Its \`verification.ok\` must be true before you add ads; an ad set that fails the check stays paused and takes none. In practice mode nothing is sent and the ad set is recorded as \`not_sent_practice\`, and ads published into it are practice too. \`${cli} meta adset show <id>\` prints it with its budget. One left \`sending\` or \`unknown\` is never sent again; \`${cli} meta adset reconcile <id>\` looks for it in the campaign by its exact name.
|
|
191
225
|
|
|
192
226
|
When you send something yourself instead, record it in ${brand.name} before anything is sent, so the record never depends on the platform remembering it:
|
|
@@ -340,6 +374,7 @@ ${cli} brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id
|
|
|
340
374
|
${cli} pages brief --step <key> [--funnel <key>] | --page <ref> the offer, step, current page, best and worst pages of its kind, its ads and how to hand a page back: start here before writing a landing page
|
|
341
375
|
${cli} pages results [--from <date>] [--to <date>] [--page <ref>] each landing page's, and each ad and page pair's, real first-touch sales, cost per sale and sales per page view
|
|
342
376
|
${cli} messages results [--from <date>] [--to <date>] [--channel email|text] [--sequence <id>] real sales after each follow-up email, text and sequence: every message the buyer clicked or replied to in the ${MESSAGE_CREDIT_DAYS} days before buying (helped), and the latest one (last nudge)
|
|
377
|
+
${cli} seo page-two [--site <site_url>] [--days <n>] [--min-impressions <n>] [--from <n>] [--to <n>] [--by query|query-page] [--limit <n>] the Google searches where the site sits on page two (average position 11 to 20), from Search Console
|
|
343
378
|
${cli} winners [--week <date> | --days <n> | --from <date> --to <date>] [--top <n>] [--ad-format image|video|carousel] which Facebook ads and landing pages won and flopped in a week or any dates and over 30 days, judged on the client's goal
|
|
344
379
|
${cli} winners goals | winners goals set <goal> [<goal> ...] | winners goals reset the goals winners judges this client on, tried in order (sales, sales:triple_whale, booked_calls, meta_results, first_leads), setting them, going back to the default
|
|
345
380
|
${cli} winners send-to-exodus [--week <date> | --days <n> | --from <date> --to <date>] [--dry-run] send the winners, with their reasons, lessons and customer words, to Exodus; safe to send again
|
|
@@ -360,9 +395,12 @@ ${cli} intake rename <id> <label> | retention <id> <days>
|
|
|
360
395
|
${cli} intake delete <id> --yes delete an address and every event it saved
|
|
361
396
|
${cli} import csv <file> --table <name> load a CSV file as the table import_<name>, replacing an earlier import
|
|
362
397
|
${cli} import remove --table <name> --yes drop the table import_<name> and its import
|
|
398
|
+
${cli} outbound push --to instantly:<campaign-id> (--csv <file> | --table <name>) [--dry-run] [--max <n>] load leads into an Instantly campaign, skipping anyone already there or pushed before
|
|
363
399
|
${cli} tools list list generation and research tools
|
|
364
400
|
${cli} tools run <tool> --input '<json>' [--idempotency-key <key>] [--wait <seconds>]
|
|
365
401
|
${cli} tools get <run-id> show a tool run, waiting for it to finish (--wait)
|
|
402
|
+
${cli} tools usage [--month <YYYY-MM>] [--by tool|run] what tool runs cost in a month, per tool or per scheduled run, split by whose key paid
|
|
403
|
+
${cli} enrich email --file <people.csv | people.json> [--steps <tool,tool>] find one verified work email per person, cheapest provider first
|
|
366
404
|
${cli} secrets list which provider keys the client has and which key runs use; never the keys themselves
|
|
367
405
|
${cli} secrets set <provider> [--file <path>] save the client's own provider key from stdin or a file (keys with "Can manage settings")
|
|
368
406
|
${cli} secrets remove <provider> remove it, so runs use the shared key again
|
|
@@ -386,6 +424,9 @@ ${cli} meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id
|
|
|
386
424
|
${cli} meta adset show <id> | meta adset reconcile <id> read an ad set publication, or check whether one left sending landed
|
|
387
425
|
${cli} meta ad create --account act_<id> --adset <id> --file <path> --text "<words>" --headline "<h>" --link <url> [--cta <type>] [--name <n>] [--status paused|active --reason "<why>"] [--run <run-id>] make a Facebook ad from files in one step
|
|
388
426
|
${cli} meta adset budget <id> | meta campaign budget <id> (--percent <n> | --daily <n>) --reason "<why>" change a daily budget within the workspace's limits
|
|
427
|
+
${cli} google-ads ads pause|resume <adgroup-id~ad-id>... | google-ads adgroup pause|resume <adgroup-id>... --reason "<why>" turn Google ads or ad groups on or off
|
|
428
|
+
${cli} google-ads campaign budget <id> (--percent <n> | --daily <amount>) --reason "<why>" change a Google Ads daily budget within the workspace's limits
|
|
429
|
+
${cli} google-ads changes [--since <date>] | google-ads changes undo <id> --reason "<why>" | google-ads changes reconcile <id> the Google Ads change log
|
|
389
430
|
${cli} content publish-record <sku> --file <publication.json> record a publish before sending it
|
|
390
431
|
${cli} content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--input '<json>']
|
|
391
432
|
${cli} content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>] tag a link with its SKU
|
package/dist/main.js
CHANGED
|
@@ -5,12 +5,14 @@ import { mkdir, readFile, stat } from "node:fs/promises";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
-
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, META_IDS_MAX, IMPORT_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, entryTagFlags, streamSigned, } from "./api.js";
|
|
8
|
+
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, META_IDS_MAX, GOOGLE_ADS_IDS_MAX, IMPORT_NAME_PATTERN, AGENT_TABLE_LIMITS, AGENT_TABLE_NAME_PATTERN, putSigned, CLIENT_FILE, parseClientName, readSavedLogins, removeCredentials, resolveApiUrl, resolveConnection, resolveLogin, UnreadableCredentialsError, writeCredentials, OUTBOUND_PUSH_MAX, entryTagFlags, streamSigned, } from "./api.js";
|
|
9
9
|
import { brand, envPrefix } from "./brand.js";
|
|
10
|
+
import { answerRow, batches, ENRICH_EMAIL_USAGE, parsePeople, parseSteps } from "./enrich.js";
|
|
10
11
|
import { META_AD_CREATE_USAGE, metaAdCreateBody } from "./meta-ad.js";
|
|
11
12
|
import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
|
|
12
13
|
import { scheduleCreateBody, scheduleIdFor, SCHEDULES_CREATE_USAGE } from "./schedules.js";
|
|
13
14
|
import { secretBody, secretProvider, SECRETS_SET_USAGE } from "./secrets.js";
|
|
15
|
+
import { dollars, toolUsageReport, toolUsageRequest, TOOLS_USAGE_USAGE } from "./tool-usage.js";
|
|
14
16
|
import { guide, skill } from "./guide.js";
|
|
15
17
|
import { PULL_STATE_FILE, pullContent } from "./pull.js";
|
|
16
18
|
import { AGENTS_MD_MARKER, metaAdsAgentsMd, PLAYBOOK_CHOICES, playbooks } from "./playbooks.js";
|
|
@@ -20,8 +22,11 @@ import { SKILL_NAME_PATTERN } from "./skill-zip.js";
|
|
|
20
22
|
import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
|
|
21
23
|
const DEFAULT_WAIT_SECONDS = 300;
|
|
22
24
|
const META_CHANGE_COLUMNS = ["id", "level", "object", "change", "status", "when", "by", "reason"];
|
|
25
|
+
const GOOGLE_ADS_CHANGE_COLUMNS = ["id", "account", "level", "object", "change", "status", "when", "by", "reason"];
|
|
23
26
|
const SERVER_WAIT_CAP = 50;
|
|
24
27
|
const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
|
|
28
|
+
const META_MOVE_USAGE = 'meta ads move <ad-id> --to-adset <adset-id> --reason "<why>" [--keep-original] [--status active|paused] [--run <run-id>] [--idempotency-key <key>]';
|
|
29
|
+
const OUTBOUND_PUSH_USAGE = `outbound push --to instantly:<campaign-id> (--csv <file> | --table <import_ or agent_ table>) [--dry-run] [--max <n>] [--idempotency-key <key>]`;
|
|
25
30
|
const commands = {
|
|
26
31
|
login: {
|
|
27
32
|
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`,
|
|
@@ -273,6 +278,45 @@ const commands = {
|
|
|
273
278
|
printRun(await waitForRun(connection, run, options.wait), options.format);
|
|
274
279
|
},
|
|
275
280
|
},
|
|
281
|
+
"enrich email": {
|
|
282
|
+
usage: ENRICH_EMAIL_USAGE,
|
|
283
|
+
async run(args, options) {
|
|
284
|
+
if (args.length > 0 || !options.file)
|
|
285
|
+
throw new CliError(`Usage: ${ENRICH_EMAIL_USAGE}`, exitCodes.usage);
|
|
286
|
+
const people = parsePeople(await readFile(await inRunFolder(options.file), "utf8"), options.file);
|
|
287
|
+
const steps = parseSteps(options.steps);
|
|
288
|
+
const connection = await resolveConnection(options);
|
|
289
|
+
const answers = [];
|
|
290
|
+
for (const batch of batches(people)) {
|
|
291
|
+
const { people: found } = await api(connection, "/api/v1/enrich/email", { people: batch, ...(steps ? { steps } : {}) });
|
|
292
|
+
answers.push(...found);
|
|
293
|
+
}
|
|
294
|
+
if (options.format === "json")
|
|
295
|
+
return printJson({ people: answers });
|
|
296
|
+
console.log(renderTable(["name", "domain", "email", "status", "found by", "cost", "run ids"], answers.map(answerRow)));
|
|
297
|
+
for (const answer of answers) {
|
|
298
|
+
for (const step of answer.steps) {
|
|
299
|
+
if (step.reason && (step.status === "failed" || step.status === "refused" || step.status === "running"))
|
|
300
|
+
console.log(`${answerRow(answer).name}: ${step.tool} ${step.status}: ${step.reason}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
"tools usage": {
|
|
306
|
+
usage: TOOLS_USAGE_USAGE,
|
|
307
|
+
async run(args, options) {
|
|
308
|
+
if (args.length > 0)
|
|
309
|
+
throw new CliError(`Usage: ${TOOLS_USAGE_USAGE}`, exitCodes.usage);
|
|
310
|
+
const { path, by } = toolUsageRequest(options);
|
|
311
|
+
const { toolUsage } = await api(await resolveConnection(options), path);
|
|
312
|
+
if (options.format === "json")
|
|
313
|
+
return printJson({ toolUsage });
|
|
314
|
+
const report = toolUsageReport(toolUsage, by);
|
|
315
|
+
console.log(report.title);
|
|
316
|
+
console.log(report.rows.length === 0 ? "No tool runs." : renderTable(report.columns, report.rows));
|
|
317
|
+
console.log(`\n${report.footer.join("\n")}`);
|
|
318
|
+
},
|
|
319
|
+
},
|
|
276
320
|
"sources list": {
|
|
277
321
|
usage: "sources list",
|
|
278
322
|
async run(args, options) {
|
|
@@ -539,13 +583,37 @@ const commands = {
|
|
|
539
583
|
const name = importName(options.table);
|
|
540
584
|
const file = await readImportFile(args[0]);
|
|
541
585
|
const connection = await resolveConnection(options);
|
|
542
|
-
const result = await uploadCsv(connection, name
|
|
586
|
+
const result = await uploadCsv(connection, `/api/v1/imports/csv?table=${encodeURIComponent(name)}`, file);
|
|
543
587
|
if (options.format === "json")
|
|
544
588
|
return printJson(result);
|
|
545
589
|
console.log(`Imported ${result.rows} ${result.rows === 1 ? "row" : "rows"} into ${result.table}.`);
|
|
546
590
|
console.log(renderTable(["column", "type", "header"], result.columns.map((c) => ({ column: c.name, type: c.type, header: c.sourceHeader }))));
|
|
547
591
|
},
|
|
548
592
|
},
|
|
593
|
+
"outbound push": {
|
|
594
|
+
usage: OUTBOUND_PUSH_USAGE,
|
|
595
|
+
async run(args, options) {
|
|
596
|
+
if (args.length !== 0 || !options.to || (options.csv === undefined) === (options.table === undefined))
|
|
597
|
+
throw new CliError(`Usage: ${OUTBOUND_PUSH_USAGE}`, exitCodes.usage);
|
|
598
|
+
const max = options.max === undefined ? OUTBOUND_PUSH_MAX.default : Number(options.max);
|
|
599
|
+
if (!Number.isInteger(max) || max < 1 || max > OUTBOUND_PUSH_MAX.ceiling) {
|
|
600
|
+
throw new CliError(`--max must be a whole number from 1 to ${OUTBOUND_PUSH_MAX.ceiling}.`, exitCodes.usage);
|
|
601
|
+
}
|
|
602
|
+
const csv = options.csv === undefined ? undefined : await readOutboundCsv(options.csv);
|
|
603
|
+
const idempotencyKey = options.dryRun ? options.idempotencyKey : announceKey(options.idempotencyKey ?? randomUUID());
|
|
604
|
+
const body = { to: options.to, csv, table: options.table, max, dryRun: options.dryRun, idempotencyKey };
|
|
605
|
+
const { push } = await api(await resolveConnection(options), "/api/v1/outbound/pushes", body);
|
|
606
|
+
if (options.format === "json")
|
|
607
|
+
printJson({ push });
|
|
608
|
+
else
|
|
609
|
+
process.stdout.write(outboundPushText(push));
|
|
610
|
+
if (push.status === "running") {
|
|
611
|
+
throw new CliError(`This push is still sending. Run the same command again with --idempotency-key ${push.idempotencyKey} in a few minutes.`, exitCodes.error);
|
|
612
|
+
}
|
|
613
|
+
if (push.status === "failed" || push.status === "partial")
|
|
614
|
+
throw new CliError(push.error ?? `The push ${push.status === "failed" ? "failed" : "was only partly sent"}.`, exitCodes.error);
|
|
615
|
+
},
|
|
616
|
+
},
|
|
549
617
|
"import remove": {
|
|
550
618
|
usage: "import remove --table <name> --yes",
|
|
551
619
|
async run(args, options) {
|
|
@@ -563,6 +631,112 @@ const commands = {
|
|
|
563
631
|
console.log(`Removed ${result.table} and its rows.`);
|
|
564
632
|
},
|
|
565
633
|
},
|
|
634
|
+
"tables list": {
|
|
635
|
+
usage: "tables list",
|
|
636
|
+
async run(args, options) {
|
|
637
|
+
if (args.length !== 0)
|
|
638
|
+
throw new CliError("Usage: tables list", exitCodes.usage);
|
|
639
|
+
const list = await api(await resolveConnection(options), TABLES_PATH);
|
|
640
|
+
if (options.format === "json")
|
|
641
|
+
return printJson(list);
|
|
642
|
+
if (list.tables.length === 0)
|
|
643
|
+
console.log("No agent tables yet. Make one with tables create.");
|
|
644
|
+
else
|
|
645
|
+
console.log(renderTable(["table", "rows", "size", "columns", "description"], list.tables.map(tableRow)));
|
|
646
|
+
console.log(`${list.usage.tables} of ${list.limits.tables} tables, ${megabytes(list.usage.bytes)} of ${list.limits.bytes / (1024 * 1024)} MB. Personal columns, marked *, hold emails or phone numbers.`);
|
|
647
|
+
},
|
|
648
|
+
},
|
|
649
|
+
"tables create": {
|
|
650
|
+
usage: 'tables create <name> --columns "<name type, ...>" [--personal <col,col>] [--description <text>]',
|
|
651
|
+
async run(args, options) {
|
|
652
|
+
const usage = 'Usage: tables create <name> --columns "keyword text, volume int" [--personal email,phone] [--description <text>]';
|
|
653
|
+
if (args.length !== 1 || !options.columns)
|
|
654
|
+
throw new CliError(usage, exitCodes.usage);
|
|
655
|
+
const personal = (options.personal ?? "").split(",").map((p) => p.trim()).filter((p) => p !== "");
|
|
656
|
+
const body = { name: agentTableName(args[0]), columns: options.columns, personal, description: options.description };
|
|
657
|
+
const { table } = await api(await resolveConnection(options), TABLES_PATH, body);
|
|
658
|
+
if (options.format === "json")
|
|
659
|
+
return printJson({ table });
|
|
660
|
+
console.log(`Made ${table.table}. Query it with warehouse query, add rows with tables insert or tables import.`);
|
|
661
|
+
console.log(renderTable(["column", "type", "personal"], table.columns.map((c) => ({ column: c.name, type: c.type, personal: c.personal ? "yes" : "" }))));
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
"tables insert": {
|
|
665
|
+
usage: "tables insert <name> --file <rows.json | rows.ndjson>",
|
|
666
|
+
async run(args, options) {
|
|
667
|
+
if (args.length !== 1 || !options.file)
|
|
668
|
+
throw new CliError("Usage: tables insert <name> --file <rows.json | rows.ndjson>", exitCodes.usage);
|
|
669
|
+
const name = agentTableName(args[0]);
|
|
670
|
+
const rows = await readRowsFile(options.file);
|
|
671
|
+
const connection = await resolveConnection(options);
|
|
672
|
+
let inserted = 0;
|
|
673
|
+
let last = null;
|
|
674
|
+
const batches = rowBatches(rows);
|
|
675
|
+
for (const batch of batches) {
|
|
676
|
+
try {
|
|
677
|
+
last = await api(connection, `${tablePath(name)}/rows`, batch);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (inserted === 0 || !(error instanceof CliError))
|
|
681
|
+
throw error;
|
|
682
|
+
throw new CliError(`${inserted} rows went in, in earlier batches, before this one was refused: ${error.message}`, error.exitCode);
|
|
683
|
+
}
|
|
684
|
+
inserted += last.inserted;
|
|
685
|
+
}
|
|
686
|
+
const result = { table: last.table, inserted, batches: batches.length, rows: last.rows, bytes: last.bytes };
|
|
687
|
+
if (options.format === "json")
|
|
688
|
+
return printJson(result);
|
|
689
|
+
console.log(`Added ${rowCount(inserted)} to ${result.table}${batches.length > 1 ? ` in ${batches.length} batches` : ""}. It now has ${rowCount(result.rows)}.`);
|
|
690
|
+
},
|
|
691
|
+
},
|
|
692
|
+
"tables import": {
|
|
693
|
+
usage: "tables import <name> --file <file.csv> [--mode append|replace] [--personal <col,col>]",
|
|
694
|
+
async run(args, options) {
|
|
695
|
+
const usage = "Usage: tables import <name> --file <file.csv> [--mode append|replace] [--personal email,phone]";
|
|
696
|
+
if (args.length !== 1 || !options.file)
|
|
697
|
+
throw new CliError(usage, exitCodes.usage);
|
|
698
|
+
const mode = options.mode ?? "append";
|
|
699
|
+
if (mode !== "append" && mode !== "replace")
|
|
700
|
+
throw new CliError("--mode must be append or replace.", exitCodes.usage);
|
|
701
|
+
const name = agentTableName(args[0]);
|
|
702
|
+
const personal = options.personal ? `&personal=${encodeURIComponent(options.personal)}` : "";
|
|
703
|
+
const file = await readImportFile(options.file);
|
|
704
|
+
const result = await uploadCsv(await resolveConnection(options), `${tablePath(name)}/import?mode=${mode}${personal}`, file);
|
|
705
|
+
if (options.format === "json")
|
|
706
|
+
return printJson(result);
|
|
707
|
+
const verb = result.created ? "Made" : mode === "replace" ? "Replaced the rows of" : "Added to";
|
|
708
|
+
console.log(`${verb} ${result.table}: ${rowCount(result.inserted)} from the file. It now has ${rowCount(result.rows)}.`);
|
|
709
|
+
if (result.created)
|
|
710
|
+
console.log(renderTable(["column", "type", "personal"], result.columns.map((c) => ({ column: c.name, type: c.type, personal: c.personal ? "yes" : "" }))));
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
"tables delete-rows": {
|
|
714
|
+
usage: "tables delete-rows <name> --where '<json>' | @file.json",
|
|
715
|
+
async run(args, options) {
|
|
716
|
+
const usage = `Usage: tables delete-rows <name> --where '{"column": "value"}'`;
|
|
717
|
+
if (args.length !== 1 || !options.where)
|
|
718
|
+
throw new CliError(usage, exitCodes.usage);
|
|
719
|
+
const where = await parseJson(options.where, "--where");
|
|
720
|
+
const result = await api(await resolveConnection(options), `${tablePath(agentTableName(args[0]))}/rows`, { where }, "DELETE");
|
|
721
|
+
if (options.format === "json")
|
|
722
|
+
return printJson(result);
|
|
723
|
+
console.log(`Deleted ${rowCount(result.deleted)} from ${result.table}. It now has ${rowCount(result.rows)}.`);
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
"tables drop": {
|
|
727
|
+
usage: "tables drop <name> --yes",
|
|
728
|
+
async run(args, options) {
|
|
729
|
+
if (args.length !== 1)
|
|
730
|
+
throw new CliError("Usage: tables drop <name> --yes", exitCodes.usage);
|
|
731
|
+
const name = agentTableName(args[0]);
|
|
732
|
+
if (!options.yes)
|
|
733
|
+
throw new CliError(`This drops the table agent_${name} and all its rows. Add --yes to confirm: tables drop ${name} --yes`, exitCodes.usage);
|
|
734
|
+
const result = await api(await resolveConnection(options), tablePath(name), undefined, "DELETE");
|
|
735
|
+
if (options.format === "json")
|
|
736
|
+
return printJson(result);
|
|
737
|
+
console.log(`Dropped ${result.table} and its rows.`);
|
|
738
|
+
},
|
|
739
|
+
},
|
|
566
740
|
"creatives list": {
|
|
567
741
|
usage: "creatives list [--status <status>] [--kind static|video|copy] [--limit <n>]",
|
|
568
742
|
async run(_args, options) {
|
|
@@ -775,6 +949,22 @@ const commands = {
|
|
|
775
949
|
printMessageResults(results);
|
|
776
950
|
},
|
|
777
951
|
},
|
|
952
|
+
"seo page-two": {
|
|
953
|
+
usage: "seo page-two [--site <site_url>] [--days <n>] [--min-impressions <n>] [--from <position>] [--to <position>] [--by query|query-page] [--limit <n>] (the Google searches where the site sits around positions 11 to 20, from Search Console)",
|
|
954
|
+
async run(args, options) {
|
|
955
|
+
if (args.length > 0)
|
|
956
|
+
throw new CliError("Usage: seo page-two [--site <site_url>] [--days <n>] [--min-impressions <n>] [--from <position>] [--to <position>] [--by query|query-page] [--limit <n>]", exitCodes.usage);
|
|
957
|
+
const query = new URLSearchParams();
|
|
958
|
+
const asked = { site: options.site, days: options.days, minImpressions: options.minImpressions, from: options.from, to: options.to, by: options.by, limit: options.limit };
|
|
959
|
+
for (const [name, value] of Object.entries(asked))
|
|
960
|
+
if (value !== undefined)
|
|
961
|
+
query.set(name, value);
|
|
962
|
+
const report = await api(await resolveConnection(options), `/api/v1/seo/page-two${query.size ? `?${query}` : ""}`);
|
|
963
|
+
if (options.format === "json")
|
|
964
|
+
return printJson(report);
|
|
965
|
+
printPageTwo(report);
|
|
966
|
+
},
|
|
967
|
+
},
|
|
778
968
|
winners: {
|
|
779
969
|
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)",
|
|
780
970
|
async run(args, options) {
|
|
@@ -1031,8 +1221,36 @@ const commands = {
|
|
|
1031
1221
|
"meta adset resume": metaStatusCommand("adset", "resume"),
|
|
1032
1222
|
"meta adset budget": metaBudgetCommand("adset"),
|
|
1033
1223
|
"meta campaign budget": metaBudgetCommand("campaign"),
|
|
1224
|
+
"meta ads move": {
|
|
1225
|
+
usage: `${META_MOVE_USAGE} copy an ad into another ad set in its account and pause the original`,
|
|
1226
|
+
async run(args, options) {
|
|
1227
|
+
if (args.length !== 1 || !options.toAdset || !options.reason)
|
|
1228
|
+
throw new CliError(`Usage: ${META_MOVE_USAGE}`, exitCodes.usage);
|
|
1229
|
+
if (options.status !== undefined && options.status !== "active" && options.status !== "paused") {
|
|
1230
|
+
throw new CliError("--status must be active or paused: how the copy starts.", exitCodes.usage);
|
|
1231
|
+
}
|
|
1232
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`move-${randomUUID()}`);
|
|
1233
|
+
const change = await api(await resolveConnection(options), "/api/v1/meta/changes", {
|
|
1234
|
+
kind: "move",
|
|
1235
|
+
adId: args[0],
|
|
1236
|
+
toAdsetId: options.toAdset,
|
|
1237
|
+
status: options.status ?? "paused",
|
|
1238
|
+
keepOriginal: options.keepOriginal,
|
|
1239
|
+
reason: options.reason,
|
|
1240
|
+
idempotencyKey,
|
|
1241
|
+
...(options.run === undefined ? {} : { runId: options.run }),
|
|
1242
|
+
});
|
|
1243
|
+
if (options.format === "json")
|
|
1244
|
+
return printJson(change);
|
|
1245
|
+
printMetaChangeResult({ changes: [change], unchanged: [] }, options.format);
|
|
1246
|
+
if (change.kind === "move" && change.status === "applied") {
|
|
1247
|
+
const original = change.after.keepOriginal ? "was left as it was" : "is paused";
|
|
1248
|
+
console.log(`Ad ${change.objectId} was copied to ad set ${change.after.adsetId} as ad ${change.after.copyId} (${change.after.copyStatus.toLowerCase()}); the original ${original}.`);
|
|
1249
|
+
}
|
|
1250
|
+
},
|
|
1251
|
+
},
|
|
1034
1252
|
"meta changes": {
|
|
1035
|
-
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off and
|
|
1253
|
+
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off, budgets changed and ads moved, newest first",
|
|
1036
1254
|
async run(args, options) {
|
|
1037
1255
|
if (args.length !== 0)
|
|
1038
1256
|
throw new CliError("Usage: meta changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
@@ -1067,6 +1285,74 @@ const commands = {
|
|
|
1067
1285
|
printMetaChangeResult({ changes: [change], unchanged: [] }, options.format);
|
|
1068
1286
|
},
|
|
1069
1287
|
},
|
|
1288
|
+
"google-ads ads pause": googleAdsStatusCommand("ads", "pause"),
|
|
1289
|
+
"google-ads ads resume": googleAdsStatusCommand("ads", "resume"),
|
|
1290
|
+
"google-ads adgroup pause": googleAdsStatusCommand("adgroup", "pause"),
|
|
1291
|
+
"google-ads adgroup resume": googleAdsStatusCommand("adgroup", "resume"),
|
|
1292
|
+
"google-ads campaign budget": {
|
|
1293
|
+
usage: 'google-ads campaign budget <campaign-id> (--percent <n> | --daily <amount>) --reason "<why>" [--idempotency-key <key>] change a Google Ads campaign\'s daily budget on the control list, within the workspace\'s limits',
|
|
1294
|
+
async run(args, options) {
|
|
1295
|
+
const usage = 'Usage: google-ads campaign budget <campaign-id> (--percent <n> | --daily <amount>) --reason "<why>" [--idempotency-key <key>]';
|
|
1296
|
+
if (args.length !== 1 || !options.reason || (options.percent === undefined) === (options.daily === undefined))
|
|
1297
|
+
throw new CliError(usage, exitCodes.usage);
|
|
1298
|
+
let amount;
|
|
1299
|
+
if (options.percent !== undefined) {
|
|
1300
|
+
const n = Number(options.percent);
|
|
1301
|
+
if (options.percent.trim() === "" || !Number.isInteger(n))
|
|
1302
|
+
throw new CliError("--percent must be a whole number.", exitCodes.usage);
|
|
1303
|
+
amount = { changePercent: n };
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
if (!/^\d+(\.\d{1,6})?$/.test(options.daily.trim()))
|
|
1307
|
+
throw new CliError("--daily must be an amount such as 60 or 60.50.", exitCodes.usage);
|
|
1308
|
+
amount = { dailyBudget: options.daily.trim() };
|
|
1309
|
+
}
|
|
1310
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`budget-${randomUUID()}`);
|
|
1311
|
+
const result = await api(await resolveConnection(options), "/api/v1/google-ads/budgets", {
|
|
1312
|
+
campaignId: args[0],
|
|
1313
|
+
...amount,
|
|
1314
|
+
reason: options.reason,
|
|
1315
|
+
idempotencyKey,
|
|
1316
|
+
});
|
|
1317
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
"google-ads changes": {
|
|
1321
|
+
usage: "google-ads changes [--since <YYYY-MM-DD>] the log of Google ads and ad groups turned on or off and budgets changed, newest first",
|
|
1322
|
+
async run(args, options) {
|
|
1323
|
+
if (args.length !== 0)
|
|
1324
|
+
throw new CliError("Usage: google-ads changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
1325
|
+
const query = new URLSearchParams();
|
|
1326
|
+
if (options.since)
|
|
1327
|
+
query.set("since", options.since);
|
|
1328
|
+
const { changes } = await api(await resolveConnection(options), `/api/v1/google-ads/changes${query.size ? `?${query}` : ""}`);
|
|
1329
|
+
if (options.format === "json")
|
|
1330
|
+
return printJson({ changes });
|
|
1331
|
+
console.log(changes.length ? renderTable(GOOGLE_ADS_CHANGE_COLUMNS, changes.map(googleAdsChangeRow)) : "No Google Ads changes yet.");
|
|
1332
|
+
},
|
|
1333
|
+
},
|
|
1334
|
+
"google-ads changes undo": {
|
|
1335
|
+
usage: 'google-ads changes undo <change-id> --reason "<why>" [--idempotency-key <key>] put the ad, ad group or budget back the way it was',
|
|
1336
|
+
async run(args, options) {
|
|
1337
|
+
if (args.length !== 1 || !options.reason)
|
|
1338
|
+
throw new CliError('Usage: google-ads changes undo <change-id> --reason "<why>" [--idempotency-key <key>]', exitCodes.usage);
|
|
1339
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`undo-${randomUUID()}`);
|
|
1340
|
+
const result = await api(await resolveConnection(options), `/api/v1/google-ads/changes/${encodeURIComponent(args[0])}/undo`, {
|
|
1341
|
+
reason: options.reason,
|
|
1342
|
+
idempotencyKey,
|
|
1343
|
+
});
|
|
1344
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
1345
|
+
},
|
|
1346
|
+
},
|
|
1347
|
+
"google-ads changes reconcile": {
|
|
1348
|
+
usage: "google-ads changes reconcile <change-id> settle a change Google's answer was lost for, from what Google reads now",
|
|
1349
|
+
async run(args, options) {
|
|
1350
|
+
if (args.length !== 1)
|
|
1351
|
+
throw new CliError("Usage: google-ads changes reconcile <change-id>", exitCodes.usage);
|
|
1352
|
+
const { change } = await api(await resolveConnection(options), `/api/v1/google-ads/changes/${encodeURIComponent(args[0])}/reconcile`, {});
|
|
1353
|
+
printGoogleAdsChangeResult({ changes: [change], unchanged: [] }, options.format);
|
|
1354
|
+
},
|
|
1355
|
+
},
|
|
1070
1356
|
"content unpublish": {
|
|
1071
1357
|
usage: "content unpublish <deployment-id> [--force]",
|
|
1072
1358
|
async run(args, options) {
|
|
@@ -2061,12 +2347,16 @@ const commands = {
|
|
|
2061
2347
|
},
|
|
2062
2348
|
},
|
|
2063
2349
|
"proposals execute": {
|
|
2064
|
-
usage: "proposals execute <proposal-id> apply an approved pause or budget proposal to Meta (live mode only), within the control list",
|
|
2350
|
+
usage: "proposals execute <proposal-id> apply an approved pause or budget proposal to Meta or Google Ads (live mode only), within the control list",
|
|
2065
2351
|
async run(args, options) {
|
|
2066
2352
|
if (args.length !== 1)
|
|
2067
2353
|
throw new CliError("Usage: proposals execute <proposal-id>", exitCodes.usage);
|
|
2068
2354
|
const result = await api(await resolveConnection(options), `/api/v1/proposals/${encodeURIComponent(args[0])}/execute`, {});
|
|
2069
|
-
|
|
2355
|
+
const google = result.changes.some((c) => "customerId" in c) || result.unchanged.some((u) => "amountMicros" in u);
|
|
2356
|
+
if (google)
|
|
2357
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
2358
|
+
else
|
|
2359
|
+
printMetaChangeResult(result, options.format);
|
|
2070
2360
|
},
|
|
2071
2361
|
},
|
|
2072
2362
|
"skill publish": {
|
|
@@ -2585,6 +2875,18 @@ function pageNumbers(r) {
|
|
|
2585
2875
|
"sales/view": r.salesPerLandingPageView,
|
|
2586
2876
|
};
|
|
2587
2877
|
}
|
|
2878
|
+
function printPageTwo(report) {
|
|
2879
|
+
if (report.missing !== null)
|
|
2880
|
+
return console.log(report.missing);
|
|
2881
|
+
const { positions, minImpressions, dates } = report;
|
|
2882
|
+
const asked = `at average position ${positions.from} to ${positions.to}, shown at least ${minImpressions} times, ${dates.from} to ${dates.to}`;
|
|
2883
|
+
if (report.keywords.length === 0)
|
|
2884
|
+
return console.log(`No searches ${asked}.`);
|
|
2885
|
+
console.log(`Searches ${asked}, most impressions first:`);
|
|
2886
|
+
const pageColumn = report.by === "query" ? "top page" : "page";
|
|
2887
|
+
const rows = report.keywords.map((k) => ({ site: k.siteUrl, query: k.query, [pageColumn]: k.page, impressions: k.impressions, clicks: k.clicks, ctr: k.ctr, position: k.position }));
|
|
2888
|
+
console.log(renderTable(["site", "query", pageColumn, "impressions", "clicks", "ctr", "position"], rows));
|
|
2889
|
+
}
|
|
2588
2890
|
function printPageResults(results) {
|
|
2589
2891
|
if (results.pages.length === 0)
|
|
2590
2892
|
return console.log(`No page results from ${results.from} to ${results.to}.`);
|
|
@@ -2864,7 +3166,10 @@ Options:
|
|
|
2864
3166
|
--retention-days <n> how many days intake create keeps each event (default: 90)
|
|
2865
3167
|
--signing-key-stdin read the sender's signing key for intake create or intake signing-key from stdin (mailgun needs one)
|
|
2866
3168
|
--table <name> the table import csv loads the file into, or import remove drops, as import_<name>;
|
|
2867
|
-
for warehouse schema, a table to describe in full; repeat for several
|
|
3169
|
+
for warehouse schema, a table to describe in full; repeat for several;
|
|
3170
|
+
for outbound push, the import_ or agent_ table holding the leads
|
|
3171
|
+
--csv <file> outbound push: the leads as a CSV file with an email column, up to 4 MB
|
|
3172
|
+
--max <n> outbound push: refuse more rows than this (default 5000, at most 50000)
|
|
2868
3173
|
--summary warehouse schema: one short row per table (kind, source, column count, summary)
|
|
2869
3174
|
--search <words> warehouse schema: describe in full the tables whose name, description or columns mention a word
|
|
2870
3175
|
--out <path> the file, or existing folder, creatives download, media download and warehouse query save to; warehouse query
|
|
@@ -2875,11 +3180,24 @@ Options:
|
|
|
2875
3180
|
--media with content pull, also save each item's files and HTML under media/ in the folder
|
|
2876
3181
|
--full with content pull, fetch every item, not only what changed since the last pull (brings new numbers)
|
|
2877
3182
|
--force content unpublish: take the page down even while delivering Meta ads link to it
|
|
3183
|
+
--to-adset <id> meta ads move: the ad set to copy the ad into, in the same ad account
|
|
3184
|
+
--keep-original meta ads move: leave the original ad as it is instead of pausing it
|
|
2878
3185
|
--plan <json> meta adset publish: the ad set to make, {"kind": "copy", ...} or {"kind": "create", ...}
|
|
2879
|
-
--
|
|
3186
|
+
--columns <list> tables create: the columns as "name type" pairs, such as "keyword text, volume int"; types are
|
|
3187
|
+
text, int, bigint, numeric, boolean, date, timestamptz and jsonb
|
|
3188
|
+
--personal <list> tables create and tables import into a new table: the columns holding emails or phone numbers,
|
|
3189
|
+
such as email,phone; the Data page never shows them (a column named for an email, phone or
|
|
3190
|
+
mobile is personal anyway)
|
|
3191
|
+
--description <text> tables create: what the table holds, which warehouse schema shows
|
|
3192
|
+
--where <json | @file.json> tables delete-rows: {column: value} pairs a row must all match to be deleted
|
|
3193
|
+
--mode append|replace tables import: add the file's rows (default) or replace every row the table has
|
|
3194
|
+
--yes confirm intake delete, import remove or tables drop, which delete saved data for good
|
|
2880
3195
|
--from <YYYY-MM-DD> first day funnel, campaigns, money, brief, pages results and messages results cover (default: 29 days before --to)
|
|
2881
|
-
--to <YYYY-MM-DD> last day funnel, campaigns, money, brief, pages results and messages results cover (default: today in the workspace reporting time zone)
|
|
3196
|
+
--to <YYYY-MM-DD> last day funnel, campaigns, money, brief, pages results and messages results cover (default: today in the workspace reporting time zone);
|
|
3197
|
+
for outbound push, where the leads go: instantly:<campaign-id>
|
|
2882
3198
|
--grain <grain> how funnel and money group days: day (default), week (starting Monday) or month
|
|
3199
|
+
--month <YYYY-MM> the month tools usage covers, in the workspace reporting time zone (default: this month)
|
|
3200
|
+
--by tool|run tools usage: one row per tool (default) or per scheduled hand run
|
|
2883
3201
|
--channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
|
|
2884
3202
|
for content track-link, where the link goes: facebook, email, sms or web (default);
|
|
2885
3203
|
with messages results, email or text
|
|
@@ -2888,9 +3206,14 @@ Options:
|
|
|
2888
3206
|
--top <n> how many best ads, worst ads, pages and campaigns brief shows, or ads in each winners list (default: 5, at most 20)
|
|
2889
3207
|
--week <YYYY-MM-DD> any day of the week winners covers, Monday to Sunday (default: the latest complete week)
|
|
2890
3208
|
--days <n> winners: the last n complete days, ending yesterday, instead of a week (at most 90); or give winners
|
|
2891
|
-
--from and --to for any dates
|
|
3209
|
+
--from and --to for any dates; seo page-two: how many days, ending on Search Console's newest day (default: 90)
|
|
3210
|
+
--site <site_url> seo page-two: only this Search Console property, such as sc-domain:example.com
|
|
3211
|
+
--min-impressions <n> seo page-two: only searches shown at least n times (default: 100)
|
|
3212
|
+
--from, --to <n> seo page-two: the average positions kept, both included (default: 11 to 20)
|
|
3213
|
+
--by query|query-page seo page-two: one row per search, with its top page (default), or one per search and page
|
|
2892
3214
|
--body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
|
|
2893
|
-
--file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several; for secrets set, the file holding the key
|
|
3215
|
+
--file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several; for secrets set, the file holding the key;
|
|
3216
|
+
for enrich email, the people: a CSV (firstName,lastName,domain,companyName,linkedinUrl) or a JSON list
|
|
2894
3217
|
--transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
|
|
2895
3218
|
--language <code> the spoken language of --transcript, such as en
|
|
2896
3219
|
--source <text> where the content content capture saves came from, in words
|
|
@@ -2898,9 +3221,11 @@ Options:
|
|
|
2898
3221
|
--tool-run <id> a succeeded tool run content create and edit link; repeat for several
|
|
2899
3222
|
--run <id> the agent run making the content (content) or the Meta change (meta ads|adset), the tool run to
|
|
2900
3223
|
record (creatives create), or the tool run whose outputs media list shows
|
|
2901
|
-
--reason <why> why: what meta ads|adset pause|resume, meta adset|campaign budget, meta changes undo
|
|
2902
|
-
|
|
2903
|
-
--
|
|
3224
|
+
--reason <why> why: what meta ads|adset pause|resume, meta ads move, meta adset|campaign budget, meta changes undo, meta ad create --status active and
|
|
3225
|
+
the google-ads commands record in the change log
|
|
3226
|
+
--percent <n> with meta adset|campaign budget or google-ads campaign budget, the whole-number percent change from the daily budget now (--percent=-10 lowers it)
|
|
3227
|
+
--daily <n> with meta adset|campaign budget, the new daily budget in the currency's smallest unit (6000 is 60.00 USD); with
|
|
3228
|
+
google-ads campaign budget, in the currency itself (60.50 is 60.50 USD)
|
|
2904
3229
|
--account <act_id> meta ad create: the ad account
|
|
2905
3230
|
--adset <id> meta ad create: the ad set the new ad goes into
|
|
2906
3231
|
--text <text | @file> meta ad create: the ad's primary text
|
|
@@ -2911,7 +3236,7 @@ Options:
|
|
|
2911
3236
|
--tool <tool> with media list, only this tool's outputs, such as kie:nano-banana
|
|
2912
3237
|
--kind image|video with media list, only images or only videos
|
|
2913
3238
|
--since, --until <YYYY-MM-DD | ISO time> with media list, made at or after --since and before --until (a day: through
|
|
2914
|
-
its end, in UTC); with meta changes, only changes from --since on
|
|
3239
|
+
its end, in UTC); with meta changes or google-ads changes, only changes from --since on
|
|
2915
3240
|
--cursor <media-id> with media list, the next page: the id it printed after "More"
|
|
2916
3241
|
--platform, --external-id where content status launched says it was published, and that platform's id for it
|
|
2917
3242
|
--object-type <type> what that platform's sync calls it (campaign_message or flow_message on klaviyo); needed where
|
|
@@ -2934,6 +3259,7 @@ Options:
|
|
|
2934
3259
|
--step is in, when two funnels have a step with that key
|
|
2935
3260
|
--step <key> with plans create, the step of --funnel this push itself is, as on the confirmed map;
|
|
2936
3261
|
with pages brief, the step whose page the brief is for
|
|
3262
|
+
--steps <a,b> with enrich email, the email tools to try (default: all four); they still run cheapest first
|
|
2937
3263
|
--page <ref> with pages brief, the page to make a new version of: its address, page key, item id or SKU;
|
|
2938
3264
|
with pages results, the one page to show: its address, page key or item id
|
|
2939
3265
|
--harness <list> which agents skill publish marks the skill for: claude, codex, or claude,codex (default)
|
|
@@ -2944,9 +3270,9 @@ Options:
|
|
|
2944
3270
|
--from, --to <n> with content diff, the two versions to compare (default: the one before the current one, and the current one)
|
|
2945
3271
|
--sort <column> how content search sorts: recent (default) or a content_scorecard number, such as click_rate or roas
|
|
2946
3272
|
--order desc|asc content search's sort direction (default: desc, best or newest first)
|
|
2947
|
-
--limit <n> how many results content search, creatives list, media list, campaigns, voice, ads tags, content tags
|
|
2948
|
-
|
|
2949
|
-
tags, or how many ads each ads tag-formats batch looks at (default 2000)
|
|
3273
|
+
--limit <n> how many results content search, creatives list, media list, campaigns, voice, ads tags, content tags,
|
|
3274
|
+
content to-check and seo page-two return, how many ads ads autotag tags and how many items content autotag
|
|
3275
|
+
tags, or how many ads each ads tag-formats batch looks at (default 2000; seo page-two: 50)
|
|
2950
3276
|
--library <slug> with knowledge search, only this library: creative-cartel, exodus or meta-policy
|
|
2951
3277
|
--angle, --hook-kind, --ad-format, --awareness <value> with knowledge search, only library entries tagged that way, and
|
|
2952
3278
|
your own ads tagged the same way with their results; with knowledge tag, the tags to put on an entry;
|
|
@@ -2998,6 +3324,8 @@ function parseOptions(argv) {
|
|
|
2998
3324
|
project: { type: "boolean", default: false },
|
|
2999
3325
|
input: { type: "string" },
|
|
3000
3326
|
"idempotency-key": { type: "string" },
|
|
3327
|
+
csv: { type: "string" },
|
|
3328
|
+
max: { type: "string" },
|
|
3001
3329
|
wait: { type: "string", default: String(DEFAULT_WAIT_SECONDS) },
|
|
3002
3330
|
status: { type: "string" },
|
|
3003
3331
|
kind: { type: "string" },
|
|
@@ -3015,6 +3343,10 @@ function parseOptions(argv) {
|
|
|
3015
3343
|
"retention-days": { type: "string" },
|
|
3016
3344
|
"signing-key-stdin": { type: "boolean", default: false },
|
|
3017
3345
|
table: { type: "string", multiple: true },
|
|
3346
|
+
columns: { type: "string" },
|
|
3347
|
+
personal: { type: "string" },
|
|
3348
|
+
description: { type: "string" },
|
|
3349
|
+
where: { type: "string" },
|
|
3018
3350
|
summary: { type: "boolean", default: false },
|
|
3019
3351
|
search: { type: "string" },
|
|
3020
3352
|
out: { type: "string" },
|
|
@@ -3024,6 +3356,8 @@ function parseOptions(argv) {
|
|
|
3024
3356
|
full: { type: "boolean", default: false },
|
|
3025
3357
|
force: { type: "boolean", default: false },
|
|
3026
3358
|
yes: { type: "boolean", default: false },
|
|
3359
|
+
"to-adset": { type: "string" },
|
|
3360
|
+
"keep-original": { type: "boolean", default: false },
|
|
3027
3361
|
from: { type: "string" },
|
|
3028
3362
|
since: { type: "string" },
|
|
3029
3363
|
until: { type: "string" },
|
|
@@ -3032,6 +3366,8 @@ function parseOptions(argv) {
|
|
|
3032
3366
|
cursor: { type: "string" },
|
|
3033
3367
|
to: { type: "string" },
|
|
3034
3368
|
grain: { type: "string" },
|
|
3369
|
+
month: { type: "string" },
|
|
3370
|
+
by: { type: "string" },
|
|
3035
3371
|
channel: { type: "string" },
|
|
3036
3372
|
campaign: { type: "string" },
|
|
3037
3373
|
account: { type: "string" },
|
|
@@ -3041,6 +3377,8 @@ function parseOptions(argv) {
|
|
|
3041
3377
|
top: { type: "string" },
|
|
3042
3378
|
week: { type: "string" },
|
|
3043
3379
|
days: { type: "string" },
|
|
3380
|
+
site: { type: "string" },
|
|
3381
|
+
"min-impressions": { type: "string" },
|
|
3044
3382
|
name: { type: "string" },
|
|
3045
3383
|
subject: { type: "string" },
|
|
3046
3384
|
preview: { type: "string" },
|
|
@@ -3083,6 +3421,7 @@ function parseOptions(argv) {
|
|
|
3083
3421
|
diff: { type: "string" },
|
|
3084
3422
|
funnel: { type: "string" },
|
|
3085
3423
|
step: { type: "string" },
|
|
3424
|
+
steps: { type: "string" },
|
|
3086
3425
|
page: { type: "string" },
|
|
3087
3426
|
sequence: { type: "string" },
|
|
3088
3427
|
workspace: { type: "string" },
|
|
@@ -3130,6 +3469,8 @@ function parseOptions(argv) {
|
|
|
3130
3469
|
project: values.project,
|
|
3131
3470
|
input: values.input,
|
|
3132
3471
|
idempotencyKey: values["idempotency-key"],
|
|
3472
|
+
csv: values.csv,
|
|
3473
|
+
max: values.max,
|
|
3133
3474
|
wait,
|
|
3134
3475
|
status: values.status,
|
|
3135
3476
|
kind: values.kind,
|
|
@@ -3148,6 +3489,10 @@ function parseOptions(argv) {
|
|
|
3148
3489
|
signingKeyStdin: values["signing-key-stdin"],
|
|
3149
3490
|
table: values.table?.at(-1),
|
|
3150
3491
|
tables: values.table ?? [],
|
|
3492
|
+
columns: values.columns,
|
|
3493
|
+
personal: values.personal,
|
|
3494
|
+
description: values.description,
|
|
3495
|
+
where: values.where,
|
|
3151
3496
|
summary: values.summary,
|
|
3152
3497
|
search: values.search,
|
|
3153
3498
|
out: values.out,
|
|
@@ -3157,6 +3502,8 @@ function parseOptions(argv) {
|
|
|
3157
3502
|
full: values.full,
|
|
3158
3503
|
force: values.force,
|
|
3159
3504
|
yes: values.yes,
|
|
3505
|
+
toAdset: values["to-adset"],
|
|
3506
|
+
keepOriginal: values["keep-original"],
|
|
3160
3507
|
from: values.from,
|
|
3161
3508
|
since: values.since,
|
|
3162
3509
|
until: values.until,
|
|
@@ -3165,6 +3512,8 @@ function parseOptions(argv) {
|
|
|
3165
3512
|
cursor: values.cursor,
|
|
3166
3513
|
to: values.to,
|
|
3167
3514
|
grain: values.grain,
|
|
3515
|
+
month: values.month,
|
|
3516
|
+
by: values.by,
|
|
3168
3517
|
channel: values.channel,
|
|
3169
3518
|
campaign: values.campaign,
|
|
3170
3519
|
account: values.account,
|
|
@@ -3174,6 +3523,8 @@ function parseOptions(argv) {
|
|
|
3174
3523
|
top: values.top,
|
|
3175
3524
|
week: values.week,
|
|
3176
3525
|
days: values.days,
|
|
3526
|
+
site: values.site,
|
|
3527
|
+
minImpressions: values["min-impressions"],
|
|
3177
3528
|
name: values.name,
|
|
3178
3529
|
subject: values.subject,
|
|
3179
3530
|
preview: values.preview,
|
|
@@ -3216,6 +3567,7 @@ function parseOptions(argv) {
|
|
|
3216
3567
|
diff: values.diff,
|
|
3217
3568
|
funnel: values.funnel,
|
|
3218
3569
|
step: values.step,
|
|
3570
|
+
steps: values.steps,
|
|
3219
3571
|
page: values.page,
|
|
3220
3572
|
sequence: values.sequence,
|
|
3221
3573
|
workspace: values.workspace,
|
|
@@ -3587,7 +3939,6 @@ function printPlan(plan, format) {
|
|
|
3587
3939
|
console.log(renderTable(["position", "sku", "kind", "role", "status", "name"], plan.items));
|
|
3588
3940
|
}
|
|
3589
3941
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3590
|
-
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
3591
3942
|
/** 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. */
|
|
3592
3943
|
async function workerId(connection, ref) {
|
|
3593
3944
|
const { workers } = await api(connection, "/api/v1/workers");
|
|
@@ -3741,18 +4092,65 @@ function metaBudgetCommand(level) {
|
|
|
3741
4092
|
},
|
|
3742
4093
|
};
|
|
3743
4094
|
}
|
|
4095
|
+
function metaChangeWords(c) {
|
|
4096
|
+
switch (c.kind) {
|
|
4097
|
+
case "status":
|
|
4098
|
+
return `${c.before.status} -> ${c.after.status}`;
|
|
4099
|
+
case "budget":
|
|
4100
|
+
return `daily budget ${c.before.dailyBudget} -> ${c.after.dailyBudget}`;
|
|
4101
|
+
case "move":
|
|
4102
|
+
return `move to ad set ${c.after.adsetId}, copy ${c.after.copyId ?? "not known yet"} (${c.after.copyStatus})${c.after.keepOriginal ? ", original kept" : ", original paused"}`;
|
|
4103
|
+
case "move_undo":
|
|
4104
|
+
return `undo move: copy ${c.after.copyId} paused, original back to ${c.after.originalStatus}`;
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
3744
4107
|
function metaChangeRow(c) {
|
|
3745
4108
|
return {
|
|
3746
4109
|
id: c.id,
|
|
3747
4110
|
level: c.level,
|
|
3748
4111
|
object: c.objectId,
|
|
3749
|
-
change: c
|
|
4112
|
+
change: metaChangeWords(c),
|
|
3750
4113
|
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
3751
4114
|
when: c.createdAt,
|
|
3752
4115
|
by: c.requestedBy,
|
|
3753
4116
|
reason: excerpt(c.reason, 60),
|
|
3754
4117
|
};
|
|
3755
4118
|
}
|
|
4119
|
+
async function readOutboundCsv(path) {
|
|
4120
|
+
const file = await readImportFile(path);
|
|
4121
|
+
if (file.byteLength > OUTBOUND_PUSH_MAX.csvBytes) {
|
|
4122
|
+
throw new CliError(`${path} is ${megabytes(file.byteLength)} MB. A push takes a CSV of at most ${OUTBOUND_PUSH_MAX.csvBytes / (1024 * 1024)} MB. Load it with \`${brand.slug} import csv\` first, then push with --table import_<name>.`, exitCodes.usage);
|
|
4123
|
+
}
|
|
4124
|
+
return file.toString("utf8");
|
|
4125
|
+
}
|
|
4126
|
+
function outboundPushText(push) {
|
|
4127
|
+
const c = push.counts;
|
|
4128
|
+
const where = `Instantly campaign ${push.campaignId}`;
|
|
4129
|
+
const lines = [
|
|
4130
|
+
push.dryRun
|
|
4131
|
+
? `Dry run: ${c.toSend} of ${c.received} ${c.received === 1 ? "row" : "rows"} would go to ${where}. Nothing was sent or saved.`
|
|
4132
|
+
: `Pushed to ${where}: ${c.added} added, ${c.skippedRemote} already in Instantly, ${c.failed} failed (${push.status}).`,
|
|
4133
|
+
renderTable(["rows", "count"], [
|
|
4134
|
+
{ rows: "received", count: String(c.received) },
|
|
4135
|
+
{ rows: "invalid email", count: String(c.invalid) },
|
|
4136
|
+
{ rows: "repeated in the list", count: String(c.duplicate) },
|
|
4137
|
+
{ rows: "already sent or synced", count: String(c.duplicateLocal) },
|
|
4138
|
+
{ rows: push.dryRun ? "would send" : "sent", count: String(c.toSend) },
|
|
4139
|
+
...(push.dryRun
|
|
4140
|
+
? []
|
|
4141
|
+
: [
|
|
4142
|
+
{ rows: "skipped by Instantly", count: String(c.skippedRemote) },
|
|
4143
|
+
{ rows: "added", count: String(c.added) },
|
|
4144
|
+
{ rows: "failed", count: String(c.failed) },
|
|
4145
|
+
]),
|
|
4146
|
+
]),
|
|
4147
|
+
];
|
|
4148
|
+
if (push.keySource)
|
|
4149
|
+
lines.push(`Key: ${push.keySource === "instantly_write" ? "the stored instantly_write key" : "the Instantly source's key"}.`);
|
|
4150
|
+
if (push.id)
|
|
4151
|
+
lines.push(`Push id: ${push.id}`);
|
|
4152
|
+
return `${lines.join("\n")}\n`;
|
|
4153
|
+
}
|
|
3756
4154
|
/** Told on stderr before the request goes out, so a request whose answer is lost can be sent again with the same key. */
|
|
3757
4155
|
function announceKey(key) {
|
|
3758
4156
|
console.error(`Idempotency key: ${key} (if no answer comes, send the same command again with --idempotency-key ${key})`);
|
|
@@ -3771,6 +4169,53 @@ function printMetaChangeResult(result, format) {
|
|
|
3771
4169
|
console.log(`Meta's answer for change ${c.id} was lost. Run \`${brand.slug} meta changes reconcile ${c.id}\` in a minute to settle it.`);
|
|
3772
4170
|
}
|
|
3773
4171
|
}
|
|
4172
|
+
function googleAdsStatusCommand(noun, verb) {
|
|
4173
|
+
const usage = `google-ads ${noun} ${verb} <${noun === "ads" ? "adgroup-id~ad-id" : "adgroup-id"}>... --reason "<why>" [--idempotency-key <key>]`;
|
|
4174
|
+
return {
|
|
4175
|
+
usage: `${usage} ${verb === "pause" ? "turn off" : "turn on"} up to ${GOOGLE_ADS_IDS_MAX} Google ${noun === "ads" ? "ads" : "ad groups"} on the control list`,
|
|
4176
|
+
async run(args, options) {
|
|
4177
|
+
if (args.length === 0 || args.length > GOOGLE_ADS_IDS_MAX || !options.reason)
|
|
4178
|
+
throw new CliError(`Usage: ${usage}`, exitCodes.usage);
|
|
4179
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`${verb}-${randomUUID()}`);
|
|
4180
|
+
const result = await api(await resolveConnection(options), "/api/v1/google-ads/changes", {
|
|
4181
|
+
level: noun === "ads" ? "ad" : "ad_group",
|
|
4182
|
+
ids: args,
|
|
4183
|
+
status: verb === "pause" ? "paused" : "enabled",
|
|
4184
|
+
reason: options.reason,
|
|
4185
|
+
idempotencyKey,
|
|
4186
|
+
});
|
|
4187
|
+
printGoogleAdsChangeResult(result, options.format);
|
|
4188
|
+
},
|
|
4189
|
+
};
|
|
4190
|
+
}
|
|
4191
|
+
/** Micros as the account's currency amount, e.g. 60500000 is 60.50. */
|
|
4192
|
+
const fromMicros = (micros) => (Number(micros) / 1_000_000).toFixed(2);
|
|
4193
|
+
function googleAdsChangeRow(c) {
|
|
4194
|
+
return {
|
|
4195
|
+
id: c.id,
|
|
4196
|
+
account: c.customerId,
|
|
4197
|
+
level: c.level,
|
|
4198
|
+
object: c.objectId,
|
|
4199
|
+
change: c.kind === "budget" ? `daily budget ${fromMicros(c.before.amountMicros)} -> ${fromMicros(c.after.amountMicros)}` : `${c.before.status} -> ${c.after.status}`,
|
|
4200
|
+
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
4201
|
+
when: c.createdAt,
|
|
4202
|
+
by: c.requestedBy,
|
|
4203
|
+
reason: excerpt(c.reason, 60),
|
|
4204
|
+
};
|
|
4205
|
+
}
|
|
4206
|
+
function printGoogleAdsChangeResult(result, format) {
|
|
4207
|
+
if (format === "json")
|
|
4208
|
+
return printJson(result);
|
|
4209
|
+
if (result.changes.length > 0)
|
|
4210
|
+
console.log(renderTable(GOOGLE_ADS_CHANGE_COLUMNS, result.changes.map(googleAdsChangeRow)));
|
|
4211
|
+
for (const u of result.unchanged) {
|
|
4212
|
+
const what = { ad: "Ad", ad_group: "Ad group", campaign: "Campaign" }[u.level];
|
|
4213
|
+
console.log("status" in u ? `${what} ${u.objectId} was already ${u.status}; nothing was sent.` : `${what} ${u.objectId}'s daily budget is already ${fromMicros(u.amountMicros)}; nothing was sent.`);
|
|
4214
|
+
}
|
|
4215
|
+
for (const c of result.changes.filter((c) => c.status === "sending")) {
|
|
4216
|
+
console.log(`Google's answer for change ${c.id} was lost. Run \`${brand.slug} google-ads changes reconcile ${c.id}\` in a minute to settle it.`);
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
3774
4219
|
function printAdSetPublication(publication, format) {
|
|
3775
4220
|
if (format === "json")
|
|
3776
4221
|
return printJson({ adsetPublication: publication });
|
|
@@ -3849,8 +4294,9 @@ function printRun(run, format) {
|
|
|
3849
4294
|
const HOSTED_UPLOAD_MB = 4.5;
|
|
3850
4295
|
const megabytes = (bytes) => Math.round((bytes / (1024 * 1024)) * 10) / 10;
|
|
3851
4296
|
/** A file over the hosted app's request limit goes to storage first, and the server imports it from there. */
|
|
3852
|
-
|
|
3853
|
-
|
|
4297
|
+
/** Sends a CSV file to an import route, `route` with its query string, through file storage first when it is too big for one request. */
|
|
4298
|
+
async function uploadCsv(connection, route, file) {
|
|
4299
|
+
const table = route;
|
|
3854
4300
|
if (file.byteLength > HOSTED_UPLOAD_MB * 1024 * 1024) {
|
|
3855
4301
|
const upload = await api(connection, "/api/v1/imports/csv/uploads", {}).catch((error) => {
|
|
3856
4302
|
// A server without storage takes the file directly, which works for anything its host lets through.
|
|
@@ -3867,13 +4313,75 @@ async function uploadCsv(connection, name, file) {
|
|
|
3867
4313
|
return await apiUpload(connection, table, file, "text/csv");
|
|
3868
4314
|
}
|
|
3869
4315
|
catch (error) {
|
|
3870
|
-
//
|
|
3871
|
-
if (error instanceof ApiError && error.status === 413 && error.code
|
|
4316
|
+
// A 413 without an error code is the hosting platform refusing the body before the app sees it.
|
|
4317
|
+
if (error instanceof ApiError && error.status === 413 && error.code === undefined) {
|
|
3872
4318
|
throw new CliError(`The server accepts at most ${HOSTED_UPLOAD_MB} MB per upload and has no file storage set up for bigger files, and this file is ${megabytes(file.byteLength)} MB. Split it into smaller files and import each under its own name.`, exitCodes.error);
|
|
3873
4319
|
}
|
|
3874
4320
|
throw error;
|
|
3875
4321
|
}
|
|
3876
4322
|
}
|
|
4323
|
+
const TABLES_PATH = "/api/v1/tables";
|
|
4324
|
+
function tablePath(name) {
|
|
4325
|
+
return `${TABLES_PATH}/${encodeURIComponent(name)}`;
|
|
4326
|
+
}
|
|
4327
|
+
/** Refuses an agent table name the server would refuse, before anything is sent. */
|
|
4328
|
+
function agentTableName(name) {
|
|
4329
|
+
if (!AGENT_TABLE_NAME_PATTERN.test(name)) {
|
|
4330
|
+
throw new CliError(`"${excerpt(name)}" can't be a table name. Use 1 to 48 lowercase letters, digits and underscores.`, exitCodes.usage);
|
|
4331
|
+
}
|
|
4332
|
+
return name;
|
|
4333
|
+
}
|
|
4334
|
+
function tableRow(t) {
|
|
4335
|
+
return {
|
|
4336
|
+
table: t.table,
|
|
4337
|
+
rows: t.rows,
|
|
4338
|
+
size: `${megabytes(t.bytes)} MB`,
|
|
4339
|
+
columns: t.columns.map((c) => `${c.name} ${c.type}${c.personal ? "*" : ""}`).join(", "),
|
|
4340
|
+
description: excerpt(t.description ?? "", 60),
|
|
4341
|
+
};
|
|
4342
|
+
}
|
|
4343
|
+
/** A JSON array of row objects, or one object per line for a .ndjson or .jsonl file. */
|
|
4344
|
+
async function readRowsFile(path) {
|
|
4345
|
+
await inRunFolder(path);
|
|
4346
|
+
const text = await readFile(path, "utf8").catch((error) => {
|
|
4347
|
+
if (error.code === "ENOENT")
|
|
4348
|
+
throw new CliError(`No file at ${path}.`, exitCodes.usage);
|
|
4349
|
+
throw error;
|
|
4350
|
+
});
|
|
4351
|
+
const lines = [".ndjson", ".jsonl"].includes(extname(path).toLowerCase());
|
|
4352
|
+
try {
|
|
4353
|
+
const rows = lines ? text.split("\n").filter((l) => l.trim() !== "").map((l) => JSON.parse(l)) : JSON.parse(text);
|
|
4354
|
+
if (!Array.isArray(rows))
|
|
4355
|
+
throw new CliError(`${path} must hold a JSON array of row objects, or one object per line in a .ndjson file.`, exitCodes.usage);
|
|
4356
|
+
if (rows.length === 0)
|
|
4357
|
+
throw new CliError(`${path} has no rows.`, exitCodes.usage);
|
|
4358
|
+
return rows;
|
|
4359
|
+
}
|
|
4360
|
+
catch (error) {
|
|
4361
|
+
if (error instanceof CliError)
|
|
4362
|
+
throw error;
|
|
4363
|
+
throw new CliError(`${path} is not valid JSON: ${error.message}`, exitCodes.usage);
|
|
4364
|
+
}
|
|
4365
|
+
}
|
|
4366
|
+
/** Splits rows into batches the server and its host each take in one request. */
|
|
4367
|
+
function rowBatches(rows) {
|
|
4368
|
+
const maxBytes = Math.min(AGENT_TABLE_LIMITS.batchBytes, (HOSTED_UPLOAD_MB - 0.5) * 1024 * 1024);
|
|
4369
|
+
const batches = [];
|
|
4370
|
+
let batch = [];
|
|
4371
|
+
let bytes = 2;
|
|
4372
|
+
for (const row of rows) {
|
|
4373
|
+
const size = Buffer.byteLength(JSON.stringify(row)) + 1;
|
|
4374
|
+
if (batch.length > 0 && (batch.length === AGENT_TABLE_LIMITS.batchRows || bytes + size > maxBytes)) {
|
|
4375
|
+
batches.push(batch);
|
|
4376
|
+
batch = [];
|
|
4377
|
+
bytes = 2;
|
|
4378
|
+
}
|
|
4379
|
+
batch.push(row);
|
|
4380
|
+
bytes += size;
|
|
4381
|
+
}
|
|
4382
|
+
batches.push(batch);
|
|
4383
|
+
return batches;
|
|
4384
|
+
}
|
|
3877
4385
|
/** Refuses a name the server would refuse, before anything is sent. */
|
|
3878
4386
|
function importName(name) {
|
|
3879
4387
|
if (!IMPORT_NAME_PATTERN.test(name)) {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { brand } from "./brand.js";
|
|
2
|
+
import { CliError, exitCodes } from "./api.js";
|
|
3
|
+
export const TOOLS_USAGE_USAGE = "tools usage [--month <YYYY-MM>] [--by tool|run]";
|
|
4
|
+
export const dollars = (cents) => `$${(Math.round(cents) / 100).toFixed(2)}`;
|
|
5
|
+
/** The API path for `tools usage`, and which breakdown to print; the server checks the month. */
|
|
6
|
+
export function toolUsageRequest(flags) {
|
|
7
|
+
const by = flags.by ?? "tool";
|
|
8
|
+
if (by !== "tool" && by !== "run")
|
|
9
|
+
throw new CliError(`--by must be tool or run, not ${JSON.stringify(by)}.`, exitCodes.usage);
|
|
10
|
+
return { path: `/api/v1/tools/usage${flags.month === undefined ? "" : `?month=${encodeURIComponent(flags.month)}`}`, by };
|
|
11
|
+
}
|
|
12
|
+
/** The statement as text: a heading, one table row per tool or per scheduled run, then the totals and source pulls. */
|
|
13
|
+
export function toolUsageReport(usage, by) {
|
|
14
|
+
const { totals } = usage;
|
|
15
|
+
const estimated = totals.estimatedCents > 0 ? ` (${dollars(totals.estimatedCents)} of it estimated)` : "";
|
|
16
|
+
const footer = [
|
|
17
|
+
`Total: ${dollars(totals.cents)} over ${totals.runs} ${totals.runs === 1 ? "run" : "runs"}${estimated}. ${brand.name}'s keys ${dollars(totals.platformCents)}, client's keys ${dollars(totals.workspaceCents)}.`,
|
|
18
|
+
`Source pulls (ScrapeCreators credits for competitor ads, Reddit and Page lookups, not tool runs): ${dollars(usage.sourcePulls.cents)}.`,
|
|
19
|
+
];
|
|
20
|
+
const title = `Tool spend for ${usage.month} (${usage.timeZone})`;
|
|
21
|
+
if (by === "run") {
|
|
22
|
+
const rows = usage.byWorkerRun.map((r) => ({ run: r.workerRunId, worker: r.worker, started: r.startedAt ?? "", runs: r.runs, cost: dollars(r.cents) }));
|
|
23
|
+
if (usage.adHoc.runs > 0)
|
|
24
|
+
rows.push({ run: "not a scheduled run", worker: "", started: "", runs: usage.adHoc.runs, cost: dollars(usage.adHoc.cents) });
|
|
25
|
+
return { title, columns: ["run", "worker", "started", "runs", "cost"], rows, footer };
|
|
26
|
+
}
|
|
27
|
+
const rows = usage.byTool.map((t) => ({
|
|
28
|
+
tool: t.tool,
|
|
29
|
+
provider: t.provider,
|
|
30
|
+
runs: t.runs,
|
|
31
|
+
failed: t.failed,
|
|
32
|
+
cost: t.estimatedOnly > 0 ? `${dollars(t.cents)} (${t.estimatedOnly} estimated)` : dollars(t.cents),
|
|
33
|
+
}));
|
|
34
|
+
return { title, columns: ["tool", "provider", "runs", "failed", "cost"], rows, footer };
|
|
35
|
+
}
|