@wrangled/cli 0.3.53 → 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 +12 -0
- package/dist/enrich.js +101 -0
- package/dist/guide.js +38 -2
- package/dist/main.js +404 -18
- package/dist/tool-usage.js +35 -0
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -21,6 +21,8 @@ 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;
|
|
26
28
|
/** The most ads or ad groups one google-ads ads|adgroup pause|resume sends; the server refuses more. */
|
|
@@ -30,6 +32,16 @@ export const MESSAGE_CREDIT_DAYS = 7;
|
|
|
30
32
|
/** Copies of the server's IMPORT_LIMITS and import name rule; src/lib/cli-consistency.test.ts keeps them in step. */
|
|
31
33
|
export const IMPORT_LIMITS = { bytes: 50 * 1024 * 1024, rows: 1_000_000, columns: 500 };
|
|
32
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}$/;
|
|
33
45
|
/** `configHome` defaults to $XDG_CONFIG_HOME, or ~/.config. */
|
|
34
46
|
export function credentialsPath(configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")) {
|
|
35
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.
|
|
@@ -342,6 +374,7 @@ ${cli} brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id
|
|
|
342
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
|
|
343
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
|
|
344
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
|
|
345
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
|
|
346
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
|
|
347
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
|
|
@@ -362,9 +395,12 @@ ${cli} intake rename <id> <label> | retention <id> <days>
|
|
|
362
395
|
${cli} intake delete <id> --yes delete an address and every event it saved
|
|
363
396
|
${cli} import csv <file> --table <name> load a CSV file as the table import_<name>, replacing an earlier import
|
|
364
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
|
|
365
399
|
${cli} tools list list generation and research tools
|
|
366
400
|
${cli} tools run <tool> --input '<json>' [--idempotency-key <key>] [--wait <seconds>]
|
|
367
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
|
|
368
404
|
${cli} secrets list which provider keys the client has and which key runs use; never the keys themselves
|
|
369
405
|
${cli} secrets set <provider> [--file <path>] save the client's own provider key from stdin or a file (keys with "Can manage settings")
|
|
370
406
|
${cli} secrets remove <provider> remove it, so runs use the shared key again
|
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, GOOGLE_ADS_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";
|
|
@@ -23,6 +25,8 @@ const META_CHANGE_COLUMNS = ["id", "level", "object", "change", "status", "when"
|
|
|
23
25
|
const GOOGLE_ADS_CHANGE_COLUMNS = ["id", "account", "level", "object", "change", "status", "when", "by", "reason"];
|
|
24
26
|
const SERVER_WAIT_CAP = 50;
|
|
25
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>]`;
|
|
26
30
|
const commands = {
|
|
27
31
|
login: {
|
|
28
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`,
|
|
@@ -274,6 +278,45 @@ const commands = {
|
|
|
274
278
|
printRun(await waitForRun(connection, run, options.wait), options.format);
|
|
275
279
|
},
|
|
276
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
|
+
},
|
|
277
320
|
"sources list": {
|
|
278
321
|
usage: "sources list",
|
|
279
322
|
async run(args, options) {
|
|
@@ -540,13 +583,37 @@ const commands = {
|
|
|
540
583
|
const name = importName(options.table);
|
|
541
584
|
const file = await readImportFile(args[0]);
|
|
542
585
|
const connection = await resolveConnection(options);
|
|
543
|
-
const result = await uploadCsv(connection, name
|
|
586
|
+
const result = await uploadCsv(connection, `/api/v1/imports/csv?table=${encodeURIComponent(name)}`, file);
|
|
544
587
|
if (options.format === "json")
|
|
545
588
|
return printJson(result);
|
|
546
589
|
console.log(`Imported ${result.rows} ${result.rows === 1 ? "row" : "rows"} into ${result.table}.`);
|
|
547
590
|
console.log(renderTable(["column", "type", "header"], result.columns.map((c) => ({ column: c.name, type: c.type, header: c.sourceHeader }))));
|
|
548
591
|
},
|
|
549
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
|
+
},
|
|
550
617
|
"import remove": {
|
|
551
618
|
usage: "import remove --table <name> --yes",
|
|
552
619
|
async run(args, options) {
|
|
@@ -564,6 +631,112 @@ const commands = {
|
|
|
564
631
|
console.log(`Removed ${result.table} and its rows.`);
|
|
565
632
|
},
|
|
566
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
|
+
},
|
|
567
740
|
"creatives list": {
|
|
568
741
|
usage: "creatives list [--status <status>] [--kind static|video|copy] [--limit <n>]",
|
|
569
742
|
async run(_args, options) {
|
|
@@ -776,6 +949,22 @@ const commands = {
|
|
|
776
949
|
printMessageResults(results);
|
|
777
950
|
},
|
|
778
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
|
+
},
|
|
779
968
|
winners: {
|
|
780
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)",
|
|
781
970
|
async run(args, options) {
|
|
@@ -1032,8 +1221,36 @@ const commands = {
|
|
|
1032
1221
|
"meta adset resume": metaStatusCommand("adset", "resume"),
|
|
1033
1222
|
"meta adset budget": metaBudgetCommand("adset"),
|
|
1034
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
|
+
},
|
|
1035
1252
|
"meta changes": {
|
|
1036
|
-
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",
|
|
1037
1254
|
async run(args, options) {
|
|
1038
1255
|
if (args.length !== 0)
|
|
1039
1256
|
throw new CliError("Usage: meta changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
@@ -2658,6 +2875,18 @@ function pageNumbers(r) {
|
|
|
2658
2875
|
"sales/view": r.salesPerLandingPageView,
|
|
2659
2876
|
};
|
|
2660
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
|
+
}
|
|
2661
2890
|
function printPageResults(results) {
|
|
2662
2891
|
if (results.pages.length === 0)
|
|
2663
2892
|
return console.log(`No page results from ${results.from} to ${results.to}.`);
|
|
@@ -2937,7 +3166,10 @@ Options:
|
|
|
2937
3166
|
--retention-days <n> how many days intake create keeps each event (default: 90)
|
|
2938
3167
|
--signing-key-stdin read the sender's signing key for intake create or intake signing-key from stdin (mailgun needs one)
|
|
2939
3168
|
--table <name> the table import csv loads the file into, or import remove drops, as import_<name>;
|
|
2940
|
-
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)
|
|
2941
3173
|
--summary warehouse schema: one short row per table (kind, source, column count, summary)
|
|
2942
3174
|
--search <words> warehouse schema: describe in full the tables whose name, description or columns mention a word
|
|
2943
3175
|
--out <path> the file, or existing folder, creatives download, media download and warehouse query save to; warehouse query
|
|
@@ -2948,11 +3180,24 @@ Options:
|
|
|
2948
3180
|
--media with content pull, also save each item's files and HTML under media/ in the folder
|
|
2949
3181
|
--full with content pull, fetch every item, not only what changed since the last pull (brings new numbers)
|
|
2950
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
|
|
2951
3185
|
--plan <json> meta adset publish: the ad set to make, {"kind": "copy", ...} or {"kind": "create", ...}
|
|
2952
|
-
--
|
|
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
|
|
2953
3195
|
--from <YYYY-MM-DD> first day funnel, campaigns, money, brief, pages results and messages results cover (default: 29 days before --to)
|
|
2954
|
-
--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>
|
|
2955
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
|
|
2956
3201
|
--channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
|
|
2957
3202
|
for content track-link, where the link goes: facebook, email, sms or web (default);
|
|
2958
3203
|
with messages results, email or text
|
|
@@ -2961,9 +3206,14 @@ Options:
|
|
|
2961
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)
|
|
2962
3207
|
--week <YYYY-MM-DD> any day of the week winners covers, Monday to Sunday (default: the latest complete week)
|
|
2963
3208
|
--days <n> winners: the last n complete days, ending yesterday, instead of a week (at most 90); or give winners
|
|
2964
|
-
--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
|
|
2965
3214
|
--body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
|
|
2966
|
-
--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
|
|
2967
3217
|
--transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
|
|
2968
3218
|
--language <code> the spoken language of --transcript, such as en
|
|
2969
3219
|
--source <text> where the content content capture saves came from, in words
|
|
@@ -2971,7 +3221,7 @@ Options:
|
|
|
2971
3221
|
--tool-run <id> a succeeded tool run content create and edit link; repeat for several
|
|
2972
3222
|
--run <id> the agent run making the content (content) or the Meta change (meta ads|adset), the tool run to
|
|
2973
3223
|
record (creatives create), or the tool run whose outputs media list shows
|
|
2974
|
-
--reason <why> why: what meta ads|adset pause|resume, meta adset|campaign budget, meta changes undo, meta ad create --status active and
|
|
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
|
|
2975
3225
|
the google-ads commands record in the change log
|
|
2976
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)
|
|
2977
3227
|
--daily <n> with meta adset|campaign budget, the new daily budget in the currency's smallest unit (6000 is 60.00 USD); with
|
|
@@ -3009,6 +3259,7 @@ Options:
|
|
|
3009
3259
|
--step is in, when two funnels have a step with that key
|
|
3010
3260
|
--step <key> with plans create, the step of --funnel this push itself is, as on the confirmed map;
|
|
3011
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
|
|
3012
3263
|
--page <ref> with pages brief, the page to make a new version of: its address, page key, item id or SKU;
|
|
3013
3264
|
with pages results, the one page to show: its address, page key or item id
|
|
3014
3265
|
--harness <list> which agents skill publish marks the skill for: claude, codex, or claude,codex (default)
|
|
@@ -3019,9 +3270,9 @@ Options:
|
|
|
3019
3270
|
--from, --to <n> with content diff, the two versions to compare (default: the one before the current one, and the current one)
|
|
3020
3271
|
--sort <column> how content search sorts: recent (default) or a content_scorecard number, such as click_rate or roas
|
|
3021
3272
|
--order desc|asc content search's sort direction (default: desc, best or newest first)
|
|
3022
|
-
--limit <n> how many results content search, creatives list, media list, campaigns, voice, ads tags, content tags
|
|
3023
|
-
|
|
3024
|
-
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)
|
|
3025
3276
|
--library <slug> with knowledge search, only this library: creative-cartel, exodus or meta-policy
|
|
3026
3277
|
--angle, --hook-kind, --ad-format, --awareness <value> with knowledge search, only library entries tagged that way, and
|
|
3027
3278
|
your own ads tagged the same way with their results; with knowledge tag, the tags to put on an entry;
|
|
@@ -3073,6 +3324,8 @@ function parseOptions(argv) {
|
|
|
3073
3324
|
project: { type: "boolean", default: false },
|
|
3074
3325
|
input: { type: "string" },
|
|
3075
3326
|
"idempotency-key": { type: "string" },
|
|
3327
|
+
csv: { type: "string" },
|
|
3328
|
+
max: { type: "string" },
|
|
3076
3329
|
wait: { type: "string", default: String(DEFAULT_WAIT_SECONDS) },
|
|
3077
3330
|
status: { type: "string" },
|
|
3078
3331
|
kind: { type: "string" },
|
|
@@ -3090,6 +3343,10 @@ function parseOptions(argv) {
|
|
|
3090
3343
|
"retention-days": { type: "string" },
|
|
3091
3344
|
"signing-key-stdin": { type: "boolean", default: false },
|
|
3092
3345
|
table: { type: "string", multiple: true },
|
|
3346
|
+
columns: { type: "string" },
|
|
3347
|
+
personal: { type: "string" },
|
|
3348
|
+
description: { type: "string" },
|
|
3349
|
+
where: { type: "string" },
|
|
3093
3350
|
summary: { type: "boolean", default: false },
|
|
3094
3351
|
search: { type: "string" },
|
|
3095
3352
|
out: { type: "string" },
|
|
@@ -3099,6 +3356,8 @@ function parseOptions(argv) {
|
|
|
3099
3356
|
full: { type: "boolean", default: false },
|
|
3100
3357
|
force: { type: "boolean", default: false },
|
|
3101
3358
|
yes: { type: "boolean", default: false },
|
|
3359
|
+
"to-adset": { type: "string" },
|
|
3360
|
+
"keep-original": { type: "boolean", default: false },
|
|
3102
3361
|
from: { type: "string" },
|
|
3103
3362
|
since: { type: "string" },
|
|
3104
3363
|
until: { type: "string" },
|
|
@@ -3107,6 +3366,8 @@ function parseOptions(argv) {
|
|
|
3107
3366
|
cursor: { type: "string" },
|
|
3108
3367
|
to: { type: "string" },
|
|
3109
3368
|
grain: { type: "string" },
|
|
3369
|
+
month: { type: "string" },
|
|
3370
|
+
by: { type: "string" },
|
|
3110
3371
|
channel: { type: "string" },
|
|
3111
3372
|
campaign: { type: "string" },
|
|
3112
3373
|
account: { type: "string" },
|
|
@@ -3116,6 +3377,8 @@ function parseOptions(argv) {
|
|
|
3116
3377
|
top: { type: "string" },
|
|
3117
3378
|
week: { type: "string" },
|
|
3118
3379
|
days: { type: "string" },
|
|
3380
|
+
site: { type: "string" },
|
|
3381
|
+
"min-impressions": { type: "string" },
|
|
3119
3382
|
name: { type: "string" },
|
|
3120
3383
|
subject: { type: "string" },
|
|
3121
3384
|
preview: { type: "string" },
|
|
@@ -3158,6 +3421,7 @@ function parseOptions(argv) {
|
|
|
3158
3421
|
diff: { type: "string" },
|
|
3159
3422
|
funnel: { type: "string" },
|
|
3160
3423
|
step: { type: "string" },
|
|
3424
|
+
steps: { type: "string" },
|
|
3161
3425
|
page: { type: "string" },
|
|
3162
3426
|
sequence: { type: "string" },
|
|
3163
3427
|
workspace: { type: "string" },
|
|
@@ -3205,6 +3469,8 @@ function parseOptions(argv) {
|
|
|
3205
3469
|
project: values.project,
|
|
3206
3470
|
input: values.input,
|
|
3207
3471
|
idempotencyKey: values["idempotency-key"],
|
|
3472
|
+
csv: values.csv,
|
|
3473
|
+
max: values.max,
|
|
3208
3474
|
wait,
|
|
3209
3475
|
status: values.status,
|
|
3210
3476
|
kind: values.kind,
|
|
@@ -3223,6 +3489,10 @@ function parseOptions(argv) {
|
|
|
3223
3489
|
signingKeyStdin: values["signing-key-stdin"],
|
|
3224
3490
|
table: values.table?.at(-1),
|
|
3225
3491
|
tables: values.table ?? [],
|
|
3492
|
+
columns: values.columns,
|
|
3493
|
+
personal: values.personal,
|
|
3494
|
+
description: values.description,
|
|
3495
|
+
where: values.where,
|
|
3226
3496
|
summary: values.summary,
|
|
3227
3497
|
search: values.search,
|
|
3228
3498
|
out: values.out,
|
|
@@ -3232,6 +3502,8 @@ function parseOptions(argv) {
|
|
|
3232
3502
|
full: values.full,
|
|
3233
3503
|
force: values.force,
|
|
3234
3504
|
yes: values.yes,
|
|
3505
|
+
toAdset: values["to-adset"],
|
|
3506
|
+
keepOriginal: values["keep-original"],
|
|
3235
3507
|
from: values.from,
|
|
3236
3508
|
since: values.since,
|
|
3237
3509
|
until: values.until,
|
|
@@ -3240,6 +3512,8 @@ function parseOptions(argv) {
|
|
|
3240
3512
|
cursor: values.cursor,
|
|
3241
3513
|
to: values.to,
|
|
3242
3514
|
grain: values.grain,
|
|
3515
|
+
month: values.month,
|
|
3516
|
+
by: values.by,
|
|
3243
3517
|
channel: values.channel,
|
|
3244
3518
|
campaign: values.campaign,
|
|
3245
3519
|
account: values.account,
|
|
@@ -3249,6 +3523,8 @@ function parseOptions(argv) {
|
|
|
3249
3523
|
top: values.top,
|
|
3250
3524
|
week: values.week,
|
|
3251
3525
|
days: values.days,
|
|
3526
|
+
site: values.site,
|
|
3527
|
+
minImpressions: values["min-impressions"],
|
|
3252
3528
|
name: values.name,
|
|
3253
3529
|
subject: values.subject,
|
|
3254
3530
|
preview: values.preview,
|
|
@@ -3291,6 +3567,7 @@ function parseOptions(argv) {
|
|
|
3291
3567
|
diff: values.diff,
|
|
3292
3568
|
funnel: values.funnel,
|
|
3293
3569
|
step: values.step,
|
|
3570
|
+
steps: values.steps,
|
|
3294
3571
|
page: values.page,
|
|
3295
3572
|
sequence: values.sequence,
|
|
3296
3573
|
workspace: values.workspace,
|
|
@@ -3662,7 +3939,6 @@ function printPlan(plan, format) {
|
|
|
3662
3939
|
console.log(renderTable(["position", "sku", "kind", "role", "status", "name"], plan.items));
|
|
3663
3940
|
}
|
|
3664
3941
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3665
|
-
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
3666
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. */
|
|
3667
3943
|
async function workerId(connection, ref) {
|
|
3668
3944
|
const { workers } = await api(connection, "/api/v1/workers");
|
|
@@ -3816,18 +4092,65 @@ function metaBudgetCommand(level) {
|
|
|
3816
4092
|
},
|
|
3817
4093
|
};
|
|
3818
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
|
+
}
|
|
3819
4107
|
function metaChangeRow(c) {
|
|
3820
4108
|
return {
|
|
3821
4109
|
id: c.id,
|
|
3822
4110
|
level: c.level,
|
|
3823
4111
|
object: c.objectId,
|
|
3824
|
-
change: c
|
|
4112
|
+
change: metaChangeWords(c),
|
|
3825
4113
|
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
3826
4114
|
when: c.createdAt,
|
|
3827
4115
|
by: c.requestedBy,
|
|
3828
4116
|
reason: excerpt(c.reason, 60),
|
|
3829
4117
|
};
|
|
3830
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
|
+
}
|
|
3831
4154
|
/** Told on stderr before the request goes out, so a request whose answer is lost can be sent again with the same key. */
|
|
3832
4155
|
function announceKey(key) {
|
|
3833
4156
|
console.error(`Idempotency key: ${key} (if no answer comes, send the same command again with --idempotency-key ${key})`);
|
|
@@ -3971,8 +4294,9 @@ function printRun(run, format) {
|
|
|
3971
4294
|
const HOSTED_UPLOAD_MB = 4.5;
|
|
3972
4295
|
const megabytes = (bytes) => Math.round((bytes / (1024 * 1024)) * 10) / 10;
|
|
3973
4296
|
/** A file over the hosted app's request limit goes to storage first, and the server imports it from there. */
|
|
3974
|
-
|
|
3975
|
-
|
|
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;
|
|
3976
4300
|
if (file.byteLength > HOSTED_UPLOAD_MB * 1024 * 1024) {
|
|
3977
4301
|
const upload = await api(connection, "/api/v1/imports/csv/uploads", {}).catch((error) => {
|
|
3978
4302
|
// A server without storage takes the file directly, which works for anything its host lets through.
|
|
@@ -3989,13 +4313,75 @@ async function uploadCsv(connection, name, file) {
|
|
|
3989
4313
|
return await apiUpload(connection, table, file, "text/csv");
|
|
3990
4314
|
}
|
|
3991
4315
|
catch (error) {
|
|
3992
|
-
//
|
|
3993
|
-
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) {
|
|
3994
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);
|
|
3995
4319
|
}
|
|
3996
4320
|
throw error;
|
|
3997
4321
|
}
|
|
3998
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
|
+
}
|
|
3999
4385
|
/** Refuses a name the server would refuse, before anything is sent. */
|
|
4000
4386
|
function importName(name) {
|
|
4001
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
|
+
}
|