@wrangled/cli 0.3.53 → 0.3.57
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/articles.js +51 -0
- package/dist/enrich.js +101 -0
- package/dist/guide.js +41 -4
- package/dist/main.js +428 -32
- 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/articles.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { CliError, exitCodes } from "./api.js";
|
|
4
|
+
// As the server reads an article (src/lib/publishing/wordpress/html.ts): a tag ends at the first > outside quotes.
|
|
5
|
+
const IMG_TAG = /<img\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
6
|
+
const ATTRIBUTE = /(\s+)([^\s"'>/=]+)(?:(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
7
|
+
/**
|
|
8
|
+
* Points each <img src> that names a --file (as given, or by its file name) at that file as sha256:<its SHA-256>,
|
|
9
|
+
* the reference a publisher replaces with the address the file gets where it is published. Other sources are left.
|
|
10
|
+
*/
|
|
11
|
+
export function pointImagesAtFiles(html, files) {
|
|
12
|
+
if (files.length === 0)
|
|
13
|
+
return html;
|
|
14
|
+
const refs = new Map();
|
|
15
|
+
for (const { path, data } of files) {
|
|
16
|
+
const ref = `sha256:${createHash("sha256").update(data).digest("hex")}`;
|
|
17
|
+
for (const name of [path, basename(path), `./${basename(path)}`])
|
|
18
|
+
refs.set(name, ref);
|
|
19
|
+
}
|
|
20
|
+
return html.replace(IMG_TAG, (tag) => {
|
|
21
|
+
let seen = false;
|
|
22
|
+
return tag.replace(ATTRIBUTE, (whole, space, name, equals, double, single, bare) => {
|
|
23
|
+
if (seen || name.toLowerCase() !== "src" || equals === undefined)
|
|
24
|
+
return whole;
|
|
25
|
+
seen = true;
|
|
26
|
+
const ref = refs.get((double ?? single ?? bare ?? "").trim());
|
|
27
|
+
if (ref === undefined)
|
|
28
|
+
return whole;
|
|
29
|
+
const quote = single !== undefined ? "'" : '"';
|
|
30
|
+
return `${space}${name}${equals}${quote}${ref}${quote}`;
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
const WORDPRESS = "wordpress:";
|
|
35
|
+
/**
|
|
36
|
+
* The publisher and target `content publish` sends. `--to wordpress:<site>` fills the target itself, with --status
|
|
37
|
+
* draft (the default) or publish; any other publisher takes its target as --target JSON, given here parsed.
|
|
38
|
+
*/
|
|
39
|
+
export function publishDestination(to, { target, status }) {
|
|
40
|
+
if (to.startsWith(WORDPRESS)) {
|
|
41
|
+
if (target !== undefined)
|
|
42
|
+
throw new CliError("--to wordpress:<site> sets the target itself; leave out --target.", exitCodes.usage);
|
|
43
|
+
const site = to.slice(WORDPRESS.length);
|
|
44
|
+
return { publisher: "wordpress", target: status === undefined ? { site } : { site, status } };
|
|
45
|
+
}
|
|
46
|
+
if (status !== undefined)
|
|
47
|
+
throw new CliError("--status goes with --to wordpress:<site>.", exitCodes.usage);
|
|
48
|
+
if (target === undefined)
|
|
49
|
+
throw new CliError(`--to ${to} needs --target '<json>'.`, exitCodes.usage);
|
|
50
|
+
return { publisher: to, target };
|
|
51
|
+
}
|
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, article, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, activecampaign, mailchimp, hubspot, kit, instantly, kajabi, omnisend, google_ads, tiktok, microsoft_ads, pinterest, linkedin, web, wordpress, 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.
|
|
@@ -181,7 +213,7 @@ Save anything you make before it goes anywhere: an ad written without a tool, an
|
|
|
181
213
|
|
|
182
214
|
### Publishing
|
|
183
215
|
|
|
184
|
-
\`${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key>\` publishes an approved item through one of ${brand.name}'s publishers, adding \`--plan <plan-id>\` when it is part of a launch plan and \`--run <run-id>\` from a hand's run. Only the approved version is published; an item edited after approval must be approved again. The server checks every rule first and refuses with each failure listed, recording nothing: one unfinished send per platform account, and at least 15 seconds between sends to one account. Otherwise it records exactly what it sends before sending it, then saves the platform's ids, marks the item launched, and moves the launch plan to live once all its items are out. For Meta without the \`--run\` of a hand in live mode, nothing is sent: the publication is saved as \`not_sent_practice\` with the full request. A send that may or may not have landed stays \`sending\` and is never retried; \`${cli} content reconcile <publication-id>\` asks the platform and finishes it. The same key returns the same publication. A page host is the publisher \`page_host:<host>\` with the target \`{"site":"...","path":"/..."}\`: it puts an approved landing page's HTML online, tags each link that leaves the page with the SKU, and is never held back by practice mode. \`${cli} content show <sku>\` lists the page's deployments with their ids. \`${cli} content unpublish <deployment-id>\` takes one live page down; the site's other pages stay up, and the deployment records when and by whom. It is refused while a synced Meta ad that is delivering links to the page, naming each ad, because a broken link in a running ad wastes money; \`--force\` takes it down anyway. Never roll a site back in Vercel's dashboard instead: the next deploy brings the rolled-away pages back.
|
|
216
|
+
\`${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key>\` publishes an approved item through one of ${brand.name}'s publishers, adding \`--plan <plan-id>\` when it is part of a launch plan and \`--run <run-id>\` from a hand's run. Only the approved version is published; an item edited after approval must be approved again. The server checks every rule first and refuses with each failure listed, recording nothing: one unfinished send per platform account, and at least 15 seconds between sends to one account. Otherwise it records exactly what it sends before sending it, then saves the platform's ids, marks the item launched, and moves the launch plan to live once all its items are out. For Meta without the \`--run\` of a hand in live mode, nothing is sent: the publication is saved as \`not_sent_practice\` with the full request. A send that may or may not have landed stays \`sending\` and is never retried; \`${cli} content reconcile <publication-id>\` asks the platform and finishes it. The same key returns the same publication. A page host is the publisher \`page_host:<host>\` with the target \`{"site":"...","path":"/..."}\`: it puts an approved landing page's HTML online, tags each link that leaves the page with the SKU, and is never held back by practice mode. \`${cli} content show <sku>\` lists the page's deployments with their ids. \`${cli} content unpublish <deployment-id>\` takes one live page down; the site's other pages stay up, and the deployment records when and by whom. It is refused while a synced Meta ad that is delivering links to the page, naming each ad, because a broken link in a running ad wastes money; \`--force\` takes it down anyway. Never roll a site back in Vercel's dashboard instead: the next deploy brings the rolled-away pages back. An article (\`${cli} content create --kind article --headline "<title>" --html @post.html --file hero.jpg\`) goes to the client's WordPress site with \`${cli} content publish <sku> --to wordpress:<site> --idempotency-key <key>\`, as a draft, or live with \`--status publish\`. Its images are uploaded once per site and each \`<img src>\` naming a \`--file\` points at the uploaded copy. The site's sign-in is saved first with \`${cli} secrets set wordpress:<site> --file wp.json\`, the file holding \`{"baseUrl": "https://...", "username": "...", "applicationPassword": "..."}\` (an application password made in WordPress under Users, Profile).
|
|
185
217
|
|
|
186
218
|
The \`meta_ads\` publisher puts an approved ad on Facebook, always paused, in an ad set that already exists, \`--to meta_ads --target '{"accountId": "act_<id>", "adsetId": "<id>"}'\`, or in one made with \`${cli} meta adset publish\` (below), \`"adsetPublicationId": "<id>"\` in place of \`adsetId\`. \`"urlTags": "sku"\` (the default) adds the SKU tracking tags to the link; \`"urlTags": "none"\` sends the link exactly as written, Meta macros such as \`{{ad.name}}\` included. The ad needs pictures or a video (not both), body text and an https link in \`fields.link_urls\` (\`fields.cta\`, such as "Shop now", sets the button; \`fields.bodies\`, \`fields.titles\` and \`fields.descriptions\` add variations). The account, the ad set's campaign and the ad set must all be on the hand's publish list, which only an operator sets, and the ad set and campaign must be in the last Meta sync and not archived. A live send also needs a run of the Facebook ads hand, Meta numbers pulled under 12 hours ago, and room under the daily limit of new ads. The ad is named \`<SKU> | <name>\` and its creative's \`url_tags\` carry the SKU. In practice mode the same command stores the full request for review and sends nothing. Each publication's \`targetSnapshot\` names the campaign and ad set as they were at publish time.
|
|
187
219
|
|
|
@@ -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,11 +395,14 @@ ${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
|
-
${cli} secrets set <provider> [--file <path>] save the client's own provider key from stdin or a file (keys with "Can manage settings")
|
|
405
|
+
${cli} secrets set <provider> [--file <path>] save the client's own provider key from stdin or a file (keys with "Can manage settings"); wordpress:<site> saves a WordPress site's sign-in
|
|
370
406
|
${cli} secrets remove <provider> remove it, so runs use the shared key again
|
|
371
407
|
${cli} creatives list list creatives (--status, --kind, --limit)
|
|
372
408
|
${cli} creatives show <sku> show one creative with its spec and lineage
|
|
@@ -382,6 +418,7 @@ ${cli} content pull <dir> [--kind <kind>] [--platform <p>] [--origin <o>] [--med
|
|
|
382
418
|
${cli} content show <sku | item-id> | content edit <sku> [...] --note <why> | content status <sku> <status>
|
|
383
419
|
${cli} content diff <sku | item-id> [--from <n>] [--to <n>] what changed between two versions
|
|
384
420
|
${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <id>] [--version <n>] [--run <run-id>] publish an approved item
|
|
421
|
+
${cli} content publish <sku> --to wordpress:<site> [--status publish] --idempotency-key <key> put an approved article on a WordPress site, as a draft unless --status publish
|
|
385
422
|
${cli} content reconcile <publication-id> check whether a publish left sending landed
|
|
386
423
|
${cli} content unpublish <deployment-id> [--force] take one deployed landing page down
|
|
387
424
|
${cli} meta adset publish --plan '<json>' --idempotency-key <key> [--run <run-id>] make a paused Meta ad set to add ads to
|
package/dist/main.js
CHANGED
|
@@ -5,12 +5,15 @@ 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 { pointImagesAtFiles, publishDestination } from "./articles.js";
|
|
16
|
+
import { dollars, toolUsageReport, toolUsageRequest, TOOLS_USAGE_USAGE } from "./tool-usage.js";
|
|
14
17
|
import { guide, skill } from "./guide.js";
|
|
15
18
|
import { PULL_STATE_FILE, pullContent } from "./pull.js";
|
|
16
19
|
import { AGENTS_MD_MARKER, metaAdsAgentsMd, PLAYBOOK_CHOICES, playbooks } from "./playbooks.js";
|
|
@@ -23,6 +26,9 @@ const META_CHANGE_COLUMNS = ["id", "level", "object", "change", "status", "when"
|
|
|
23
26
|
const GOOGLE_ADS_CHANGE_COLUMNS = ["id", "account", "level", "object", "change", "status", "when", "by", "reason"];
|
|
24
27
|
const SERVER_WAIT_CAP = 50;
|
|
25
28
|
const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
|
|
29
|
+
const CONTENT_PUBLISH_USAGE = "content publish <sku | item-id> (--to <publisher> --target '<json>' | --to wordpress:<site> [--status draft|publish]) --idempotency-key <key> [--plan <plan-id>] [--version <n>] [--run <run-id>]";
|
|
30
|
+
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>]';
|
|
31
|
+
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
32
|
const commands = {
|
|
27
33
|
login: {
|
|
28
34
|
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 +280,45 @@ const commands = {
|
|
|
274
280
|
printRun(await waitForRun(connection, run, options.wait), options.format);
|
|
275
281
|
},
|
|
276
282
|
},
|
|
283
|
+
"enrich email": {
|
|
284
|
+
usage: ENRICH_EMAIL_USAGE,
|
|
285
|
+
async run(args, options) {
|
|
286
|
+
if (args.length > 0 || !options.file)
|
|
287
|
+
throw new CliError(`Usage: ${ENRICH_EMAIL_USAGE}`, exitCodes.usage);
|
|
288
|
+
const people = parsePeople(await readFile(await inRunFolder(options.file), "utf8"), options.file);
|
|
289
|
+
const steps = parseSteps(options.steps);
|
|
290
|
+
const connection = await resolveConnection(options);
|
|
291
|
+
const answers = [];
|
|
292
|
+
for (const batch of batches(people)) {
|
|
293
|
+
const { people: found } = await api(connection, "/api/v1/enrich/email", { people: batch, ...(steps ? { steps } : {}) });
|
|
294
|
+
answers.push(...found);
|
|
295
|
+
}
|
|
296
|
+
if (options.format === "json")
|
|
297
|
+
return printJson({ people: answers });
|
|
298
|
+
console.log(renderTable(["name", "domain", "email", "status", "found by", "cost", "run ids"], answers.map(answerRow)));
|
|
299
|
+
for (const answer of answers) {
|
|
300
|
+
for (const step of answer.steps) {
|
|
301
|
+
if (step.reason && (step.status === "failed" || step.status === "refused" || step.status === "running"))
|
|
302
|
+
console.log(`${answerRow(answer).name}: ${step.tool} ${step.status}: ${step.reason}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
"tools usage": {
|
|
308
|
+
usage: TOOLS_USAGE_USAGE,
|
|
309
|
+
async run(args, options) {
|
|
310
|
+
if (args.length > 0)
|
|
311
|
+
throw new CliError(`Usage: ${TOOLS_USAGE_USAGE}`, exitCodes.usage);
|
|
312
|
+
const { path, by } = toolUsageRequest(options);
|
|
313
|
+
const { toolUsage } = await api(await resolveConnection(options), path);
|
|
314
|
+
if (options.format === "json")
|
|
315
|
+
return printJson({ toolUsage });
|
|
316
|
+
const report = toolUsageReport(toolUsage, by);
|
|
317
|
+
console.log(report.title);
|
|
318
|
+
console.log(report.rows.length === 0 ? "No tool runs." : renderTable(report.columns, report.rows));
|
|
319
|
+
console.log(`\n${report.footer.join("\n")}`);
|
|
320
|
+
},
|
|
321
|
+
},
|
|
277
322
|
"sources list": {
|
|
278
323
|
usage: "sources list",
|
|
279
324
|
async run(args, options) {
|
|
@@ -405,6 +450,9 @@ const commands = {
|
|
|
405
450
|
const { secret } = await api(connection, secretPath(provider), secretBody(text), "PUT");
|
|
406
451
|
if (options.format === "json")
|
|
407
452
|
return printJson({ secret });
|
|
453
|
+
if (secret.provider.startsWith("wordpress:")) {
|
|
454
|
+
return console.log(`Saved the sign-in for the WordPress site ${secret.provider.slice("wordpress:".length)}. content publish --to ${secret.provider} uses it.`);
|
|
455
|
+
}
|
|
408
456
|
console.log(`Saved the client's own ${secret.provider} key. Its ${secret.provider} runs now use it instead of the shared one.`);
|
|
409
457
|
},
|
|
410
458
|
},
|
|
@@ -540,13 +588,37 @@ const commands = {
|
|
|
540
588
|
const name = importName(options.table);
|
|
541
589
|
const file = await readImportFile(args[0]);
|
|
542
590
|
const connection = await resolveConnection(options);
|
|
543
|
-
const result = await uploadCsv(connection, name
|
|
591
|
+
const result = await uploadCsv(connection, `/api/v1/imports/csv?table=${encodeURIComponent(name)}`, file);
|
|
544
592
|
if (options.format === "json")
|
|
545
593
|
return printJson(result);
|
|
546
594
|
console.log(`Imported ${result.rows} ${result.rows === 1 ? "row" : "rows"} into ${result.table}.`);
|
|
547
595
|
console.log(renderTable(["column", "type", "header"], result.columns.map((c) => ({ column: c.name, type: c.type, header: c.sourceHeader }))));
|
|
548
596
|
},
|
|
549
597
|
},
|
|
598
|
+
"outbound push": {
|
|
599
|
+
usage: OUTBOUND_PUSH_USAGE,
|
|
600
|
+
async run(args, options) {
|
|
601
|
+
if (args.length !== 0 || !options.to || (options.csv === undefined) === (options.table === undefined))
|
|
602
|
+
throw new CliError(`Usage: ${OUTBOUND_PUSH_USAGE}`, exitCodes.usage);
|
|
603
|
+
const max = options.max === undefined ? OUTBOUND_PUSH_MAX.default : Number(options.max);
|
|
604
|
+
if (!Number.isInteger(max) || max < 1 || max > OUTBOUND_PUSH_MAX.ceiling) {
|
|
605
|
+
throw new CliError(`--max must be a whole number from 1 to ${OUTBOUND_PUSH_MAX.ceiling}.`, exitCodes.usage);
|
|
606
|
+
}
|
|
607
|
+
const csv = options.csv === undefined ? undefined : await readOutboundCsv(options.csv);
|
|
608
|
+
const idempotencyKey = options.dryRun ? options.idempotencyKey : announceKey(options.idempotencyKey ?? randomUUID());
|
|
609
|
+
const body = { to: options.to, csv, table: options.table, max, dryRun: options.dryRun, idempotencyKey };
|
|
610
|
+
const { push } = await api(await resolveConnection(options), "/api/v1/outbound/pushes", body);
|
|
611
|
+
if (options.format === "json")
|
|
612
|
+
printJson({ push });
|
|
613
|
+
else
|
|
614
|
+
process.stdout.write(outboundPushText(push));
|
|
615
|
+
if (push.status === "running") {
|
|
616
|
+
throw new CliError(`This push is still sending. Run the same command again with --idempotency-key ${push.idempotencyKey} in a few minutes.`, exitCodes.error);
|
|
617
|
+
}
|
|
618
|
+
if (push.status === "failed" || push.status === "partial")
|
|
619
|
+
throw new CliError(push.error ?? `The push ${push.status === "failed" ? "failed" : "was only partly sent"}.`, exitCodes.error);
|
|
620
|
+
},
|
|
621
|
+
},
|
|
550
622
|
"import remove": {
|
|
551
623
|
usage: "import remove --table <name> --yes",
|
|
552
624
|
async run(args, options) {
|
|
@@ -564,6 +636,112 @@ const commands = {
|
|
|
564
636
|
console.log(`Removed ${result.table} and its rows.`);
|
|
565
637
|
},
|
|
566
638
|
},
|
|
639
|
+
"tables list": {
|
|
640
|
+
usage: "tables list",
|
|
641
|
+
async run(args, options) {
|
|
642
|
+
if (args.length !== 0)
|
|
643
|
+
throw new CliError("Usage: tables list", exitCodes.usage);
|
|
644
|
+
const list = await api(await resolveConnection(options), TABLES_PATH);
|
|
645
|
+
if (options.format === "json")
|
|
646
|
+
return printJson(list);
|
|
647
|
+
if (list.tables.length === 0)
|
|
648
|
+
console.log("No agent tables yet. Make one with tables create.");
|
|
649
|
+
else
|
|
650
|
+
console.log(renderTable(["table", "rows", "size", "columns", "description"], list.tables.map(tableRow)));
|
|
651
|
+
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.`);
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
"tables create": {
|
|
655
|
+
usage: 'tables create <name> --columns "<name type, ...>" [--personal <col,col>] [--description <text>]',
|
|
656
|
+
async run(args, options) {
|
|
657
|
+
const usage = 'Usage: tables create <name> --columns "keyword text, volume int" [--personal email,phone] [--description <text>]';
|
|
658
|
+
if (args.length !== 1 || !options.columns)
|
|
659
|
+
throw new CliError(usage, exitCodes.usage);
|
|
660
|
+
const personal = (options.personal ?? "").split(",").map((p) => p.trim()).filter((p) => p !== "");
|
|
661
|
+
const body = { name: agentTableName(args[0]), columns: options.columns, personal, description: options.description };
|
|
662
|
+
const { table } = await api(await resolveConnection(options), TABLES_PATH, body);
|
|
663
|
+
if (options.format === "json")
|
|
664
|
+
return printJson({ table });
|
|
665
|
+
console.log(`Made ${table.table}. Query it with warehouse query, add rows with tables insert or tables import.`);
|
|
666
|
+
console.log(renderTable(["column", "type", "personal"], table.columns.map((c) => ({ column: c.name, type: c.type, personal: c.personal ? "yes" : "" }))));
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
"tables insert": {
|
|
670
|
+
usage: "tables insert <name> --file <rows.json | rows.ndjson>",
|
|
671
|
+
async run(args, options) {
|
|
672
|
+
if (args.length !== 1 || !options.file)
|
|
673
|
+
throw new CliError("Usage: tables insert <name> --file <rows.json | rows.ndjson>", exitCodes.usage);
|
|
674
|
+
const name = agentTableName(args[0]);
|
|
675
|
+
const rows = await readRowsFile(options.file);
|
|
676
|
+
const connection = await resolveConnection(options);
|
|
677
|
+
let inserted = 0;
|
|
678
|
+
let last = null;
|
|
679
|
+
const batches = rowBatches(rows);
|
|
680
|
+
for (const batch of batches) {
|
|
681
|
+
try {
|
|
682
|
+
last = await api(connection, `${tablePath(name)}/rows`, batch);
|
|
683
|
+
}
|
|
684
|
+
catch (error) {
|
|
685
|
+
if (inserted === 0 || !(error instanceof CliError))
|
|
686
|
+
throw error;
|
|
687
|
+
throw new CliError(`${inserted} rows went in, in earlier batches, before this one was refused: ${error.message}`, error.exitCode);
|
|
688
|
+
}
|
|
689
|
+
inserted += last.inserted;
|
|
690
|
+
}
|
|
691
|
+
const result = { table: last.table, inserted, batches: batches.length, rows: last.rows, bytes: last.bytes };
|
|
692
|
+
if (options.format === "json")
|
|
693
|
+
return printJson(result);
|
|
694
|
+
console.log(`Added ${rowCount(inserted)} to ${result.table}${batches.length > 1 ? ` in ${batches.length} batches` : ""}. It now has ${rowCount(result.rows)}.`);
|
|
695
|
+
},
|
|
696
|
+
},
|
|
697
|
+
"tables import": {
|
|
698
|
+
usage: "tables import <name> --file <file.csv> [--mode append|replace] [--personal <col,col>]",
|
|
699
|
+
async run(args, options) {
|
|
700
|
+
const usage = "Usage: tables import <name> --file <file.csv> [--mode append|replace] [--personal email,phone]";
|
|
701
|
+
if (args.length !== 1 || !options.file)
|
|
702
|
+
throw new CliError(usage, exitCodes.usage);
|
|
703
|
+
const mode = options.mode ?? "append";
|
|
704
|
+
if (mode !== "append" && mode !== "replace")
|
|
705
|
+
throw new CliError("--mode must be append or replace.", exitCodes.usage);
|
|
706
|
+
const name = agentTableName(args[0]);
|
|
707
|
+
const personal = options.personal ? `&personal=${encodeURIComponent(options.personal)}` : "";
|
|
708
|
+
const file = await readImportFile(options.file);
|
|
709
|
+
const result = await uploadCsv(await resolveConnection(options), `${tablePath(name)}/import?mode=${mode}${personal}`, file);
|
|
710
|
+
if (options.format === "json")
|
|
711
|
+
return printJson(result);
|
|
712
|
+
const verb = result.created ? "Made" : mode === "replace" ? "Replaced the rows of" : "Added to";
|
|
713
|
+
console.log(`${verb} ${result.table}: ${rowCount(result.inserted)} from the file. It now has ${rowCount(result.rows)}.`);
|
|
714
|
+
if (result.created)
|
|
715
|
+
console.log(renderTable(["column", "type", "personal"], result.columns.map((c) => ({ column: c.name, type: c.type, personal: c.personal ? "yes" : "" }))));
|
|
716
|
+
},
|
|
717
|
+
},
|
|
718
|
+
"tables delete-rows": {
|
|
719
|
+
usage: "tables delete-rows <name> --where '<json>' | @file.json",
|
|
720
|
+
async run(args, options) {
|
|
721
|
+
const usage = `Usage: tables delete-rows <name> --where '{"column": "value"}'`;
|
|
722
|
+
if (args.length !== 1 || !options.where)
|
|
723
|
+
throw new CliError(usage, exitCodes.usage);
|
|
724
|
+
const where = await parseJson(options.where, "--where");
|
|
725
|
+
const result = await api(await resolveConnection(options), `${tablePath(agentTableName(args[0]))}/rows`, { where }, "DELETE");
|
|
726
|
+
if (options.format === "json")
|
|
727
|
+
return printJson(result);
|
|
728
|
+
console.log(`Deleted ${rowCount(result.deleted)} from ${result.table}. It now has ${rowCount(result.rows)}.`);
|
|
729
|
+
},
|
|
730
|
+
},
|
|
731
|
+
"tables drop": {
|
|
732
|
+
usage: "tables drop <name> --yes",
|
|
733
|
+
async run(args, options) {
|
|
734
|
+
if (args.length !== 1)
|
|
735
|
+
throw new CliError("Usage: tables drop <name> --yes", exitCodes.usage);
|
|
736
|
+
const name = agentTableName(args[0]);
|
|
737
|
+
if (!options.yes)
|
|
738
|
+
throw new CliError(`This drops the table agent_${name} and all its rows. Add --yes to confirm: tables drop ${name} --yes`, exitCodes.usage);
|
|
739
|
+
const result = await api(await resolveConnection(options), tablePath(name), undefined, "DELETE");
|
|
740
|
+
if (options.format === "json")
|
|
741
|
+
return printJson(result);
|
|
742
|
+
console.log(`Dropped ${result.table} and its rows.`);
|
|
743
|
+
},
|
|
744
|
+
},
|
|
567
745
|
"creatives list": {
|
|
568
746
|
usage: "creatives list [--status <status>] [--kind static|video|copy] [--limit <n>]",
|
|
569
747
|
async run(_args, options) {
|
|
@@ -685,11 +863,11 @@ const commands = {
|
|
|
685
863
|
usage: "content create --kind <kind> [--name <name>] [--subject <s>] [--preview <s>] [--headline <s>] [--body <text> | @file] [--html @file] [--fields '<json>' | @file.json] [--file <path> ...] [--tool-run <id> ...] [--run <agent-run-id>] [--note <why>] [--plan <plan-id> [--role <role>]]",
|
|
686
864
|
async run(args, options) {
|
|
687
865
|
if (args.length > 0 || !options.kind)
|
|
688
|
-
throw new CliError("Usage: content create --kind ad|email|sms|landing_page|video|social_post [...]", exitCodes.usage);
|
|
866
|
+
throw new CliError("Usage: content create --kind ad|email|sms|landing_page|video|social_post|article [...]", exitCodes.usage);
|
|
689
867
|
const connection = await resolveConnection(options);
|
|
690
868
|
const content = await api(connection, "/api/v1/content", {
|
|
691
869
|
kind: options.kind,
|
|
692
|
-
...(await contentInput(options, connection)),
|
|
870
|
+
...(await contentInput(options, connection, async () => options.kind)),
|
|
693
871
|
...(options.plan === undefined ? {} : { launchPlanId: options.plan }),
|
|
694
872
|
...(options.role === undefined ? {} : { launchPlanRole: options.role }),
|
|
695
873
|
});
|
|
@@ -707,7 +885,7 @@ const commands = {
|
|
|
707
885
|
const connection = await resolveConnection(options);
|
|
708
886
|
const content = await api(connection, "/api/v1/content/captures", {
|
|
709
887
|
kind: options.kind,
|
|
710
|
-
...(await contentInput(options, connection)),
|
|
888
|
+
...(await contentInput(options, connection, async () => options.kind)),
|
|
711
889
|
transcript: options.transcript === undefined ? undefined : { text: await textOrFile(options.transcript), language: options.language },
|
|
712
890
|
source: options.source,
|
|
713
891
|
url: options.url,
|
|
@@ -776,6 +954,22 @@ const commands = {
|
|
|
776
954
|
printMessageResults(results);
|
|
777
955
|
},
|
|
778
956
|
},
|
|
957
|
+
"seo page-two": {
|
|
958
|
+
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)",
|
|
959
|
+
async run(args, options) {
|
|
960
|
+
if (args.length > 0)
|
|
961
|
+
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);
|
|
962
|
+
const query = new URLSearchParams();
|
|
963
|
+
const asked = { site: options.site, days: options.days, minImpressions: options.minImpressions, from: options.from, to: options.to, by: options.by, limit: options.limit };
|
|
964
|
+
for (const [name, value] of Object.entries(asked))
|
|
965
|
+
if (value !== undefined)
|
|
966
|
+
query.set(name, value);
|
|
967
|
+
const report = await api(await resolveConnection(options), `/api/v1/seo/page-two${query.size ? `?${query}` : ""}`);
|
|
968
|
+
if (options.format === "json")
|
|
969
|
+
return printJson(report);
|
|
970
|
+
printPageTwo(report);
|
|
971
|
+
},
|
|
972
|
+
},
|
|
779
973
|
winners: {
|
|
780
974
|
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
975
|
async run(args, options) {
|
|
@@ -918,7 +1112,7 @@ const commands = {
|
|
|
918
1112
|
if (args.length !== 1)
|
|
919
1113
|
throw new CliError("Usage: content edit <sku | item-id> [--body <text> | @file] [--note <why>] [...]", exitCodes.usage);
|
|
920
1114
|
const connection = await resolveConnection(options);
|
|
921
|
-
const input = await contentInput(options, connection);
|
|
1115
|
+
const input = await contentInput(options, connection, async () => (await api(connection, `/api/v1/content/${encodeURIComponent(args[0])}`)).item.kind);
|
|
922
1116
|
if (Object.keys(input).every((k) => k === "runId" || k === "note")) {
|
|
923
1117
|
throw new CliError("content edit needs something to change, such as --body, --subject or --file.", exitCodes.usage);
|
|
924
1118
|
}
|
|
@@ -941,17 +1135,19 @@ const commands = {
|
|
|
941
1135
|
},
|
|
942
1136
|
},
|
|
943
1137
|
"content publish": {
|
|
944
|
-
usage:
|
|
1138
|
+
usage: CONTENT_PUBLISH_USAGE,
|
|
945
1139
|
async run(args, options) {
|
|
946
|
-
if (args.length !== 1 || !options.to ||
|
|
947
|
-
throw new CliError(
|
|
948
|
-
}
|
|
1140
|
+
if (args.length !== 1 || !options.to || !options.idempotencyKey)
|
|
1141
|
+
throw new CliError(`Usage: ${CONTENT_PUBLISH_USAGE}`, exitCodes.usage);
|
|
949
1142
|
const version = options.version === undefined ? undefined : Number(options.version);
|
|
950
1143
|
if (version !== undefined && !(Number.isInteger(version) && version >= 1))
|
|
951
1144
|
throw new CliError("--version must be a whole number from 1.", exitCodes.usage);
|
|
1145
|
+
const destination = publishDestination(options.to, {
|
|
1146
|
+
target: options.target === undefined ? undefined : await parseJson(options.target, "--target"),
|
|
1147
|
+
status: options.status,
|
|
1148
|
+
});
|
|
952
1149
|
const { publication } = await api(await resolveConnection(options), `/api/v1/content/${encodeURIComponent(args[0])}/publish`, {
|
|
953
|
-
|
|
954
|
-
target: await parseJson(options.target, "--target"),
|
|
1150
|
+
...destination,
|
|
955
1151
|
idempotencyKey: options.idempotencyKey,
|
|
956
1152
|
...(options.plan === undefined ? {} : { launchPlanId: options.plan }),
|
|
957
1153
|
...(version === undefined ? {} : { version }),
|
|
@@ -1032,8 +1228,36 @@ const commands = {
|
|
|
1032
1228
|
"meta adset resume": metaStatusCommand("adset", "resume"),
|
|
1033
1229
|
"meta adset budget": metaBudgetCommand("adset"),
|
|
1034
1230
|
"meta campaign budget": metaBudgetCommand("campaign"),
|
|
1231
|
+
"meta ads move": {
|
|
1232
|
+
usage: `${META_MOVE_USAGE} copy an ad into another ad set in its account and pause the original`,
|
|
1233
|
+
async run(args, options) {
|
|
1234
|
+
if (args.length !== 1 || !options.toAdset || !options.reason)
|
|
1235
|
+
throw new CliError(`Usage: ${META_MOVE_USAGE}`, exitCodes.usage);
|
|
1236
|
+
if (options.status !== undefined && options.status !== "active" && options.status !== "paused") {
|
|
1237
|
+
throw new CliError("--status must be active or paused: how the copy starts.", exitCodes.usage);
|
|
1238
|
+
}
|
|
1239
|
+
const idempotencyKey = options.idempotencyKey ?? announceKey(`move-${randomUUID()}`);
|
|
1240
|
+
const change = await api(await resolveConnection(options), "/api/v1/meta/changes", {
|
|
1241
|
+
kind: "move",
|
|
1242
|
+
adId: args[0],
|
|
1243
|
+
toAdsetId: options.toAdset,
|
|
1244
|
+
status: options.status ?? "paused",
|
|
1245
|
+
keepOriginal: options.keepOriginal,
|
|
1246
|
+
reason: options.reason,
|
|
1247
|
+
idempotencyKey,
|
|
1248
|
+
...(options.run === undefined ? {} : { runId: options.run }),
|
|
1249
|
+
});
|
|
1250
|
+
if (options.format === "json")
|
|
1251
|
+
return printJson(change);
|
|
1252
|
+
printMetaChangeResult({ changes: [change], unchanged: [] }, options.format);
|
|
1253
|
+
if (change.kind === "move" && change.status === "applied") {
|
|
1254
|
+
const original = change.after.keepOriginal ? "was left as it was" : "is paused";
|
|
1255
|
+
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}.`);
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
},
|
|
1035
1259
|
"meta changes": {
|
|
1036
|
-
usage: "meta changes [--since <YYYY-MM-DD>] the log of Meta ads and ad sets turned on or off and
|
|
1260
|
+
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
1261
|
async run(args, options) {
|
|
1038
1262
|
if (args.length !== 0)
|
|
1039
1263
|
throw new CliError("Usage: meta changes [--since <YYYY-MM-DD>]", exitCodes.usage);
|
|
@@ -2658,6 +2882,18 @@ function pageNumbers(r) {
|
|
|
2658
2882
|
"sales/view": r.salesPerLandingPageView,
|
|
2659
2883
|
};
|
|
2660
2884
|
}
|
|
2885
|
+
function printPageTwo(report) {
|
|
2886
|
+
if (report.missing !== null)
|
|
2887
|
+
return console.log(report.missing);
|
|
2888
|
+
const { positions, minImpressions, dates } = report;
|
|
2889
|
+
const asked = `at average position ${positions.from} to ${positions.to}, shown at least ${minImpressions} times, ${dates.from} to ${dates.to}`;
|
|
2890
|
+
if (report.keywords.length === 0)
|
|
2891
|
+
return console.log(`No searches ${asked}.`);
|
|
2892
|
+
console.log(`Searches ${asked}, most impressions first:`);
|
|
2893
|
+
const pageColumn = report.by === "query" ? "top page" : "page";
|
|
2894
|
+
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 }));
|
|
2895
|
+
console.log(renderTable(["site", "query", pageColumn, "impressions", "clicks", "ctr", "position"], rows));
|
|
2896
|
+
}
|
|
2661
2897
|
function printPageResults(results) {
|
|
2662
2898
|
if (results.pages.length === 0)
|
|
2663
2899
|
return console.log(`No page results from ${results.from} to ${results.to}.`);
|
|
@@ -2937,7 +3173,10 @@ Options:
|
|
|
2937
3173
|
--retention-days <n> how many days intake create keeps each event (default: 90)
|
|
2938
3174
|
--signing-key-stdin read the sender's signing key for intake create or intake signing-key from stdin (mailgun needs one)
|
|
2939
3175
|
--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
|
|
3176
|
+
for warehouse schema, a table to describe in full; repeat for several;
|
|
3177
|
+
for outbound push, the import_ or agent_ table holding the leads
|
|
3178
|
+
--csv <file> outbound push: the leads as a CSV file with an email column, up to 4 MB
|
|
3179
|
+
--max <n> outbound push: refuse more rows than this (default 5000, at most 50000)
|
|
2941
3180
|
--summary warehouse schema: one short row per table (kind, source, column count, summary)
|
|
2942
3181
|
--search <words> warehouse schema: describe in full the tables whose name, description or columns mention a word
|
|
2943
3182
|
--out <path> the file, or existing folder, creatives download, media download and warehouse query save to; warehouse query
|
|
@@ -2948,11 +3187,24 @@ Options:
|
|
|
2948
3187
|
--media with content pull, also save each item's files and HTML under media/ in the folder
|
|
2949
3188
|
--full with content pull, fetch every item, not only what changed since the last pull (brings new numbers)
|
|
2950
3189
|
--force content unpublish: take the page down even while delivering Meta ads link to it
|
|
3190
|
+
--to-adset <id> meta ads move: the ad set to copy the ad into, in the same ad account
|
|
3191
|
+
--keep-original meta ads move: leave the original ad as it is instead of pausing it
|
|
2951
3192
|
--plan <json> meta adset publish: the ad set to make, {"kind": "copy", ...} or {"kind": "create", ...}
|
|
2952
|
-
--
|
|
3193
|
+
--columns <list> tables create: the columns as "name type" pairs, such as "keyword text, volume int"; types are
|
|
3194
|
+
text, int, bigint, numeric, boolean, date, timestamptz and jsonb
|
|
3195
|
+
--personal <list> tables create and tables import into a new table: the columns holding emails or phone numbers,
|
|
3196
|
+
such as email,phone; the Data page never shows them (a column named for an email, phone or
|
|
3197
|
+
mobile is personal anyway)
|
|
3198
|
+
--description <text> tables create: what the table holds, which warehouse schema shows
|
|
3199
|
+
--where <json | @file.json> tables delete-rows: {column: value} pairs a row must all match to be deleted
|
|
3200
|
+
--mode append|replace tables import: add the file's rows (default) or replace every row the table has
|
|
3201
|
+
--yes confirm intake delete, import remove or tables drop, which delete saved data for good
|
|
2953
3202
|
--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)
|
|
3203
|
+
--to <YYYY-MM-DD> last day funnel, campaigns, money, brief, pages results and messages results cover (default: today in the workspace reporting time zone);
|
|
3204
|
+
for outbound push, where the leads go: instantly:<campaign-id>
|
|
2955
3205
|
--grain <grain> how funnel and money group days: day (default), week (starting Monday) or month
|
|
3206
|
+
--month <YYYY-MM> the month tools usage covers, in the workspace reporting time zone (default: this month)
|
|
3207
|
+
--by tool|run tools usage: one row per tool (default) or per scheduled hand run
|
|
2956
3208
|
--channel <channel> only this channel's campaigns (and, with brief, its ads): meta, google, affiliate, a utm_source, unknown or recorded_in_crm;
|
|
2957
3209
|
for content track-link, where the link goes: facebook, email, sms or web (default);
|
|
2958
3210
|
with messages results, email or text
|
|
@@ -2961,9 +3213,14 @@ Options:
|
|
|
2961
3213
|
--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
3214
|
--week <YYYY-MM-DD> any day of the week winners covers, Monday to Sunday (default: the latest complete week)
|
|
2963
3215
|
--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
|
|
3216
|
+
--from and --to for any dates; seo page-two: how many days, ending on Search Console's newest day (default: 90)
|
|
3217
|
+
--site <site_url> seo page-two: only this Search Console property, such as sc-domain:example.com
|
|
3218
|
+
--min-impressions <n> seo page-two: only searches shown at least n times (default: 100)
|
|
3219
|
+
--from, --to <n> seo page-two: the average positions kept, both included (default: 11 to 20)
|
|
3220
|
+
--by query|query-page seo page-two: one row per search, with its top page (default), or one per search and page
|
|
2965
3221
|
--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
|
|
3222
|
+
--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;
|
|
3223
|
+
for enrich email, the people: a CSV (firstName,lastName,domain,companyName,linkedinUrl) or a JSON list
|
|
2967
3224
|
--transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
|
|
2968
3225
|
--language <code> the spoken language of --transcript, such as en
|
|
2969
3226
|
--source <text> where the content content capture saves came from, in words
|
|
@@ -2971,7 +3228,7 @@ Options:
|
|
|
2971
3228
|
--tool-run <id> a succeeded tool run content create and edit link; repeat for several
|
|
2972
3229
|
--run <id> the agent run making the content (content) or the Meta change (meta ads|adset), the tool run to
|
|
2973
3230
|
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
|
|
3231
|
+
--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
3232
|
the google-ads commands record in the change log
|
|
2976
3233
|
--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
3234
|
--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 +3266,7 @@ Options:
|
|
|
3009
3266
|
--step is in, when two funnels have a step with that key
|
|
3010
3267
|
--step <key> with plans create, the step of --funnel this push itself is, as on the confirmed map;
|
|
3011
3268
|
with pages brief, the step whose page the brief is for
|
|
3269
|
+
--steps <a,b> with enrich email, the email tools to try (default: all four); they still run cheapest first
|
|
3012
3270
|
--page <ref> with pages brief, the page to make a new version of: its address, page key, item id or SKU;
|
|
3013
3271
|
with pages results, the one page to show: its address, page key or item id
|
|
3014
3272
|
--harness <list> which agents skill publish marks the skill for: claude, codex, or claude,codex (default)
|
|
@@ -3019,9 +3277,9 @@ Options:
|
|
|
3019
3277
|
--from, --to <n> with content diff, the two versions to compare (default: the one before the current one, and the current one)
|
|
3020
3278
|
--sort <column> how content search sorts: recent (default) or a content_scorecard number, such as click_rate or roas
|
|
3021
3279
|
--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)
|
|
3280
|
+
--limit <n> how many results content search, creatives list, media list, campaigns, voice, ads tags, content tags,
|
|
3281
|
+
content to-check and seo page-two return, how many ads ads autotag tags and how many items content autotag
|
|
3282
|
+
tags, or how many ads each ads tag-formats batch looks at (default 2000; seo page-two: 50)
|
|
3025
3283
|
--library <slug> with knowledge search, only this library: creative-cartel, exodus or meta-policy
|
|
3026
3284
|
--angle, --hook-kind, --ad-format, --awareness <value> with knowledge search, only library entries tagged that way, and
|
|
3027
3285
|
your own ads tagged the same way with their results; with knowledge tag, the tags to put on an entry;
|
|
@@ -3073,6 +3331,8 @@ function parseOptions(argv) {
|
|
|
3073
3331
|
project: { type: "boolean", default: false },
|
|
3074
3332
|
input: { type: "string" },
|
|
3075
3333
|
"idempotency-key": { type: "string" },
|
|
3334
|
+
csv: { type: "string" },
|
|
3335
|
+
max: { type: "string" },
|
|
3076
3336
|
wait: { type: "string", default: String(DEFAULT_WAIT_SECONDS) },
|
|
3077
3337
|
status: { type: "string" },
|
|
3078
3338
|
kind: { type: "string" },
|
|
@@ -3090,6 +3350,10 @@ function parseOptions(argv) {
|
|
|
3090
3350
|
"retention-days": { type: "string" },
|
|
3091
3351
|
"signing-key-stdin": { type: "boolean", default: false },
|
|
3092
3352
|
table: { type: "string", multiple: true },
|
|
3353
|
+
columns: { type: "string" },
|
|
3354
|
+
personal: { type: "string" },
|
|
3355
|
+
description: { type: "string" },
|
|
3356
|
+
where: { type: "string" },
|
|
3093
3357
|
summary: { type: "boolean", default: false },
|
|
3094
3358
|
search: { type: "string" },
|
|
3095
3359
|
out: { type: "string" },
|
|
@@ -3099,6 +3363,8 @@ function parseOptions(argv) {
|
|
|
3099
3363
|
full: { type: "boolean", default: false },
|
|
3100
3364
|
force: { type: "boolean", default: false },
|
|
3101
3365
|
yes: { type: "boolean", default: false },
|
|
3366
|
+
"to-adset": { type: "string" },
|
|
3367
|
+
"keep-original": { type: "boolean", default: false },
|
|
3102
3368
|
from: { type: "string" },
|
|
3103
3369
|
since: { type: "string" },
|
|
3104
3370
|
until: { type: "string" },
|
|
@@ -3107,6 +3373,8 @@ function parseOptions(argv) {
|
|
|
3107
3373
|
cursor: { type: "string" },
|
|
3108
3374
|
to: { type: "string" },
|
|
3109
3375
|
grain: { type: "string" },
|
|
3376
|
+
month: { type: "string" },
|
|
3377
|
+
by: { type: "string" },
|
|
3110
3378
|
channel: { type: "string" },
|
|
3111
3379
|
campaign: { type: "string" },
|
|
3112
3380
|
account: { type: "string" },
|
|
@@ -3116,6 +3384,8 @@ function parseOptions(argv) {
|
|
|
3116
3384
|
top: { type: "string" },
|
|
3117
3385
|
week: { type: "string" },
|
|
3118
3386
|
days: { type: "string" },
|
|
3387
|
+
site: { type: "string" },
|
|
3388
|
+
"min-impressions": { type: "string" },
|
|
3119
3389
|
name: { type: "string" },
|
|
3120
3390
|
subject: { type: "string" },
|
|
3121
3391
|
preview: { type: "string" },
|
|
@@ -3158,6 +3428,7 @@ function parseOptions(argv) {
|
|
|
3158
3428
|
diff: { type: "string" },
|
|
3159
3429
|
funnel: { type: "string" },
|
|
3160
3430
|
step: { type: "string" },
|
|
3431
|
+
steps: { type: "string" },
|
|
3161
3432
|
page: { type: "string" },
|
|
3162
3433
|
sequence: { type: "string" },
|
|
3163
3434
|
workspace: { type: "string" },
|
|
@@ -3205,6 +3476,8 @@ function parseOptions(argv) {
|
|
|
3205
3476
|
project: values.project,
|
|
3206
3477
|
input: values.input,
|
|
3207
3478
|
idempotencyKey: values["idempotency-key"],
|
|
3479
|
+
csv: values.csv,
|
|
3480
|
+
max: values.max,
|
|
3208
3481
|
wait,
|
|
3209
3482
|
status: values.status,
|
|
3210
3483
|
kind: values.kind,
|
|
@@ -3223,6 +3496,10 @@ function parseOptions(argv) {
|
|
|
3223
3496
|
signingKeyStdin: values["signing-key-stdin"],
|
|
3224
3497
|
table: values.table?.at(-1),
|
|
3225
3498
|
tables: values.table ?? [],
|
|
3499
|
+
columns: values.columns,
|
|
3500
|
+
personal: values.personal,
|
|
3501
|
+
description: values.description,
|
|
3502
|
+
where: values.where,
|
|
3226
3503
|
summary: values.summary,
|
|
3227
3504
|
search: values.search,
|
|
3228
3505
|
out: values.out,
|
|
@@ -3232,6 +3509,8 @@ function parseOptions(argv) {
|
|
|
3232
3509
|
full: values.full,
|
|
3233
3510
|
force: values.force,
|
|
3234
3511
|
yes: values.yes,
|
|
3512
|
+
toAdset: values["to-adset"],
|
|
3513
|
+
keepOriginal: values["keep-original"],
|
|
3235
3514
|
from: values.from,
|
|
3236
3515
|
since: values.since,
|
|
3237
3516
|
until: values.until,
|
|
@@ -3240,6 +3519,8 @@ function parseOptions(argv) {
|
|
|
3240
3519
|
cursor: values.cursor,
|
|
3241
3520
|
to: values.to,
|
|
3242
3521
|
grain: values.grain,
|
|
3522
|
+
month: values.month,
|
|
3523
|
+
by: values.by,
|
|
3243
3524
|
channel: values.channel,
|
|
3244
3525
|
campaign: values.campaign,
|
|
3245
3526
|
account: values.account,
|
|
@@ -3249,6 +3530,8 @@ function parseOptions(argv) {
|
|
|
3249
3530
|
top: values.top,
|
|
3250
3531
|
week: values.week,
|
|
3251
3532
|
days: values.days,
|
|
3533
|
+
site: values.site,
|
|
3534
|
+
minImpressions: values["min-impressions"],
|
|
3252
3535
|
name: values.name,
|
|
3253
3536
|
subject: values.subject,
|
|
3254
3537
|
preview: values.preview,
|
|
@@ -3291,6 +3574,7 @@ function parseOptions(argv) {
|
|
|
3291
3574
|
diff: values.diff,
|
|
3292
3575
|
funnel: values.funnel,
|
|
3293
3576
|
step: values.step,
|
|
3577
|
+
steps: values.steps,
|
|
3294
3578
|
page: values.page,
|
|
3295
3579
|
sequence: values.sequence,
|
|
3296
3580
|
workspace: values.workspace,
|
|
@@ -3494,9 +3778,12 @@ const INLINE_CONTENT_BYTES = 3 * 1024 * 1024;
|
|
|
3494
3778
|
* The request body the content create and edit flags describe; flags not given are left out. Files go base64 while
|
|
3495
3779
|
* they and the HTML fit in one request; any other file goes to storage first and is named by its upload id.
|
|
3496
3780
|
*/
|
|
3497
|
-
async function contentInput(options, connection) {
|
|
3498
|
-
const
|
|
3499
|
-
const
|
|
3781
|
+
async function contentInput(options, connection, kindOf) {
|
|
3782
|
+
const read = await readContentFiles(options.files);
|
|
3783
|
+
const given = options.html === undefined ? undefined : await textOrFile(options.html);
|
|
3784
|
+
// Only an article's publisher swaps sha256: sources for uploaded files; any other kind keeps its HTML as written.
|
|
3785
|
+
const html = given !== undefined && read.length > 0 && (await kindOf()) === "article" ? pointImagesAtFiles(given, read) : given;
|
|
3786
|
+
const files = await sendableFiles(read, connection, html === undefined ? 0 : Buffer.byteLength(html));
|
|
3500
3787
|
const input = {
|
|
3501
3788
|
name: options.name,
|
|
3502
3789
|
subject: options.subject,
|
|
@@ -3524,7 +3811,7 @@ async function readContentFiles(paths) {
|
|
|
3524
3811
|
const contentType = FILE_TYPES[extname(path).toLowerCase()];
|
|
3525
3812
|
if (!contentType)
|
|
3526
3813
|
throw new CliError(`${path}: --file takes ${Object.keys(FILE_TYPES).join(", ")} files.`, exitCodes.usage);
|
|
3527
|
-
read.push({ contentType, data: await readFile(await inRunFolder(path)) });
|
|
3814
|
+
read.push({ path, contentType, data: await readFile(await inRunFolder(path)) });
|
|
3528
3815
|
}
|
|
3529
3816
|
return read;
|
|
3530
3817
|
}
|
|
@@ -3662,7 +3949,6 @@ function printPlan(plan, format) {
|
|
|
3662
3949
|
console.log(renderTable(["position", "sku", "kind", "role", "status", "name"], plan.items));
|
|
3663
3950
|
}
|
|
3664
3951
|
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
3952
|
/** 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
3953
|
async function workerId(connection, ref) {
|
|
3668
3954
|
const { workers } = await api(connection, "/api/v1/workers");
|
|
@@ -3816,18 +4102,65 @@ function metaBudgetCommand(level) {
|
|
|
3816
4102
|
},
|
|
3817
4103
|
};
|
|
3818
4104
|
}
|
|
4105
|
+
function metaChangeWords(c) {
|
|
4106
|
+
switch (c.kind) {
|
|
4107
|
+
case "status":
|
|
4108
|
+
return `${c.before.status} -> ${c.after.status}`;
|
|
4109
|
+
case "budget":
|
|
4110
|
+
return `daily budget ${c.before.dailyBudget} -> ${c.after.dailyBudget}`;
|
|
4111
|
+
case "move":
|
|
4112
|
+
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"}`;
|
|
4113
|
+
case "move_undo":
|
|
4114
|
+
return `undo move: copy ${c.after.copyId} paused, original back to ${c.after.originalStatus}`;
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
3819
4117
|
function metaChangeRow(c) {
|
|
3820
4118
|
return {
|
|
3821
4119
|
id: c.id,
|
|
3822
4120
|
level: c.level,
|
|
3823
4121
|
object: c.objectId,
|
|
3824
|
-
change: c
|
|
4122
|
+
change: metaChangeWords(c),
|
|
3825
4123
|
status: c.undoneBy ? `${c.status} (undone by ${c.undoneBy})` : c.error ? `${c.status}: ${c.error}` : c.status,
|
|
3826
4124
|
when: c.createdAt,
|
|
3827
4125
|
by: c.requestedBy,
|
|
3828
4126
|
reason: excerpt(c.reason, 60),
|
|
3829
4127
|
};
|
|
3830
4128
|
}
|
|
4129
|
+
async function readOutboundCsv(path) {
|
|
4130
|
+
const file = await readImportFile(path);
|
|
4131
|
+
if (file.byteLength > OUTBOUND_PUSH_MAX.csvBytes) {
|
|
4132
|
+
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);
|
|
4133
|
+
}
|
|
4134
|
+
return file.toString("utf8");
|
|
4135
|
+
}
|
|
4136
|
+
function outboundPushText(push) {
|
|
4137
|
+
const c = push.counts;
|
|
4138
|
+
const where = `Instantly campaign ${push.campaignId}`;
|
|
4139
|
+
const lines = [
|
|
4140
|
+
push.dryRun
|
|
4141
|
+
? `Dry run: ${c.toSend} of ${c.received} ${c.received === 1 ? "row" : "rows"} would go to ${where}. Nothing was sent or saved.`
|
|
4142
|
+
: `Pushed to ${where}: ${c.added} added, ${c.skippedRemote} already in Instantly, ${c.failed} failed (${push.status}).`,
|
|
4143
|
+
renderTable(["rows", "count"], [
|
|
4144
|
+
{ rows: "received", count: String(c.received) },
|
|
4145
|
+
{ rows: "invalid email", count: String(c.invalid) },
|
|
4146
|
+
{ rows: "repeated in the list", count: String(c.duplicate) },
|
|
4147
|
+
{ rows: "already sent or synced", count: String(c.duplicateLocal) },
|
|
4148
|
+
{ rows: push.dryRun ? "would send" : "sent", count: String(c.toSend) },
|
|
4149
|
+
...(push.dryRun
|
|
4150
|
+
? []
|
|
4151
|
+
: [
|
|
4152
|
+
{ rows: "skipped by Instantly", count: String(c.skippedRemote) },
|
|
4153
|
+
{ rows: "added", count: String(c.added) },
|
|
4154
|
+
{ rows: "failed", count: String(c.failed) },
|
|
4155
|
+
]),
|
|
4156
|
+
]),
|
|
4157
|
+
];
|
|
4158
|
+
if (push.keySource)
|
|
4159
|
+
lines.push(`Key: ${push.keySource === "instantly_write" ? "the stored instantly_write key" : "the Instantly source's key"}.`);
|
|
4160
|
+
if (push.id)
|
|
4161
|
+
lines.push(`Push id: ${push.id}`);
|
|
4162
|
+
return `${lines.join("\n")}\n`;
|
|
4163
|
+
}
|
|
3831
4164
|
/** Told on stderr before the request goes out, so a request whose answer is lost can be sent again with the same key. */
|
|
3832
4165
|
function announceKey(key) {
|
|
3833
4166
|
console.error(`Idempotency key: ${key} (if no answer comes, send the same command again with --idempotency-key ${key})`);
|
|
@@ -3971,8 +4304,9 @@ function printRun(run, format) {
|
|
|
3971
4304
|
const HOSTED_UPLOAD_MB = 4.5;
|
|
3972
4305
|
const megabytes = (bytes) => Math.round((bytes / (1024 * 1024)) * 10) / 10;
|
|
3973
4306
|
/** A file over the hosted app's request limit goes to storage first, and the server imports it from there. */
|
|
3974
|
-
|
|
3975
|
-
|
|
4307
|
+
/** 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. */
|
|
4308
|
+
async function uploadCsv(connection, route, file) {
|
|
4309
|
+
const table = route;
|
|
3976
4310
|
if (file.byteLength > HOSTED_UPLOAD_MB * 1024 * 1024) {
|
|
3977
4311
|
const upload = await api(connection, "/api/v1/imports/csv/uploads", {}).catch((error) => {
|
|
3978
4312
|
// A server without storage takes the file directly, which works for anything its host lets through.
|
|
@@ -3989,13 +4323,75 @@ async function uploadCsv(connection, name, file) {
|
|
|
3989
4323
|
return await apiUpload(connection, table, file, "text/csv");
|
|
3990
4324
|
}
|
|
3991
4325
|
catch (error) {
|
|
3992
|
-
//
|
|
3993
|
-
if (error instanceof ApiError && error.status === 413 && error.code
|
|
4326
|
+
// A 413 without an error code is the hosting platform refusing the body before the app sees it.
|
|
4327
|
+
if (error instanceof ApiError && error.status === 413 && error.code === undefined) {
|
|
3994
4328
|
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
4329
|
}
|
|
3996
4330
|
throw error;
|
|
3997
4331
|
}
|
|
3998
4332
|
}
|
|
4333
|
+
const TABLES_PATH = "/api/v1/tables";
|
|
4334
|
+
function tablePath(name) {
|
|
4335
|
+
return `${TABLES_PATH}/${encodeURIComponent(name)}`;
|
|
4336
|
+
}
|
|
4337
|
+
/** Refuses an agent table name the server would refuse, before anything is sent. */
|
|
4338
|
+
function agentTableName(name) {
|
|
4339
|
+
if (!AGENT_TABLE_NAME_PATTERN.test(name)) {
|
|
4340
|
+
throw new CliError(`"${excerpt(name)}" can't be a table name. Use 1 to 48 lowercase letters, digits and underscores.`, exitCodes.usage);
|
|
4341
|
+
}
|
|
4342
|
+
return name;
|
|
4343
|
+
}
|
|
4344
|
+
function tableRow(t) {
|
|
4345
|
+
return {
|
|
4346
|
+
table: t.table,
|
|
4347
|
+
rows: t.rows,
|
|
4348
|
+
size: `${megabytes(t.bytes)} MB`,
|
|
4349
|
+
columns: t.columns.map((c) => `${c.name} ${c.type}${c.personal ? "*" : ""}`).join(", "),
|
|
4350
|
+
description: excerpt(t.description ?? "", 60),
|
|
4351
|
+
};
|
|
4352
|
+
}
|
|
4353
|
+
/** A JSON array of row objects, or one object per line for a .ndjson or .jsonl file. */
|
|
4354
|
+
async function readRowsFile(path) {
|
|
4355
|
+
await inRunFolder(path);
|
|
4356
|
+
const text = await readFile(path, "utf8").catch((error) => {
|
|
4357
|
+
if (error.code === "ENOENT")
|
|
4358
|
+
throw new CliError(`No file at ${path}.`, exitCodes.usage);
|
|
4359
|
+
throw error;
|
|
4360
|
+
});
|
|
4361
|
+
const lines = [".ndjson", ".jsonl"].includes(extname(path).toLowerCase());
|
|
4362
|
+
try {
|
|
4363
|
+
const rows = lines ? text.split("\n").filter((l) => l.trim() !== "").map((l) => JSON.parse(l)) : JSON.parse(text);
|
|
4364
|
+
if (!Array.isArray(rows))
|
|
4365
|
+
throw new CliError(`${path} must hold a JSON array of row objects, or one object per line in a .ndjson file.`, exitCodes.usage);
|
|
4366
|
+
if (rows.length === 0)
|
|
4367
|
+
throw new CliError(`${path} has no rows.`, exitCodes.usage);
|
|
4368
|
+
return rows;
|
|
4369
|
+
}
|
|
4370
|
+
catch (error) {
|
|
4371
|
+
if (error instanceof CliError)
|
|
4372
|
+
throw error;
|
|
4373
|
+
throw new CliError(`${path} is not valid JSON: ${error.message}`, exitCodes.usage);
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
/** Splits rows into batches the server and its host each take in one request. */
|
|
4377
|
+
function rowBatches(rows) {
|
|
4378
|
+
const maxBytes = Math.min(AGENT_TABLE_LIMITS.batchBytes, (HOSTED_UPLOAD_MB - 0.5) * 1024 * 1024);
|
|
4379
|
+
const batches = [];
|
|
4380
|
+
let batch = [];
|
|
4381
|
+
let bytes = 2;
|
|
4382
|
+
for (const row of rows) {
|
|
4383
|
+
const size = Buffer.byteLength(JSON.stringify(row)) + 1;
|
|
4384
|
+
if (batch.length > 0 && (batch.length === AGENT_TABLE_LIMITS.batchRows || bytes + size > maxBytes)) {
|
|
4385
|
+
batches.push(batch);
|
|
4386
|
+
batch = [];
|
|
4387
|
+
bytes = 2;
|
|
4388
|
+
}
|
|
4389
|
+
batch.push(row);
|
|
4390
|
+
bytes += size;
|
|
4391
|
+
}
|
|
4392
|
+
batches.push(batch);
|
|
4393
|
+
return batches;
|
|
4394
|
+
}
|
|
3999
4395
|
/** Refuses a name the server would refuse, before anything is sent. */
|
|
4000
4396
|
function importName(name) {
|
|
4001
4397
|
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
|
+
}
|