@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 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 { IMPORT_LIMITS, MESSAGE_CREDIT_DAYS } from "./api.js";
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, file);
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 budgets changed, newest first",
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
- printMetaChangeResult(result, options.format);
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
- --yes confirm intake delete or import remove, which delete saved data for good
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 and meta ad create --status active record in the change log
2902
- --percent <n> with meta adset|campaign budget, the whole-number percent change from Meta's daily budget now (--percent=-10 lowers it)
2903
- --daily <n> with meta adset|campaign budget, the new daily budget in the currency's smallest unit (6000 is 60.00 USD)
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
- and content to-check return, how many ads ads autotag tags and how many items content autotag
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.kind === "budget" ? `daily budget ${c.before.dailyBudget} -> ${c.after.dailyBudget}` : `${c.before.status} -> ${c.after.status}`,
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
- async function uploadCsv(connection, name, file) {
3853
- const table = `/api/v1/imports/csv?table=${encodeURIComponent(name)}`;
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
- // The importer's own 413 is too_large; any other 413 is the hosting platform refusing the body before the app sees it.
3871
- if (error instanceof ApiError && error.status === 413 && error.code !== "too_large") {
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.52",
3
+ "version": "0.3.56",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {