@wrangled/cli 0.3.43 → 0.3.45
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 +140 -23
- package/dist/guide.js +9 -8
- package/dist/main.js +222 -143
- package/dist/playbooks.js +17 -17
- package/dist/runner/run.js +1 -1
- package/dist/skills.js +3 -8
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { brand, envPrefix } from "./brand.js";
|
|
5
|
-
import { writeFileAtomic } from "./files.js";
|
|
5
|
+
import { RUN_ROOT_ENV, writeFileAtomic } from "./files.js";
|
|
6
6
|
export const exitCodes = { ok: 0, error: 1, usage: 2, auth: 3, interrupted: 130 };
|
|
7
7
|
export class CliError extends Error {
|
|
8
8
|
exitCode;
|
|
@@ -30,8 +30,17 @@ export const IMPORT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
|
|
|
30
30
|
export function credentialsPath(configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")) {
|
|
31
31
|
return join(configHome, brand.slug, "credentials.json");
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
const CLIENT_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
34
|
+
/** The file that names a folder's client; it holds only the name, never a key. */
|
|
35
|
+
export const CLIENT_FILE = `.${brand.slug}-client`;
|
|
36
|
+
export function parseClientName(name) {
|
|
37
|
+
if (!CLIENT_NAME.test(name)) {
|
|
38
|
+
throw new CliError(`'${name}' is not a client name: use lowercase letters, digits and dashes, starting with a letter or digit.`, exitCodes.usage);
|
|
39
|
+
}
|
|
40
|
+
return name;
|
|
41
|
+
}
|
|
42
|
+
export async function readSavedLogins(configHome) {
|
|
43
|
+
const path = credentialsPath(configHome);
|
|
35
44
|
let text;
|
|
36
45
|
try {
|
|
37
46
|
text = await readFile(path, "utf8");
|
|
@@ -41,38 +50,146 @@ export async function readCredentials() {
|
|
|
41
50
|
return null;
|
|
42
51
|
throw error;
|
|
43
52
|
}
|
|
44
|
-
const
|
|
45
|
-
if (!
|
|
53
|
+
const saved = parseSavedLogins(text);
|
|
54
|
+
if (!saved)
|
|
46
55
|
throw new UnreadableCredentialsError(path);
|
|
47
|
-
return
|
|
56
|
+
return saved;
|
|
48
57
|
}
|
|
49
|
-
function
|
|
58
|
+
function parseSavedLogins(text) {
|
|
59
|
+
let value;
|
|
50
60
|
try {
|
|
51
|
-
|
|
52
|
-
if (typeof value?.apiKey !== "string" || typeof value.apiUrl !== "string")
|
|
53
|
-
return null;
|
|
54
|
-
return { apiKey: value.apiKey, apiUrl: value.apiUrl };
|
|
61
|
+
value = JSON.parse(text);
|
|
55
62
|
}
|
|
56
63
|
catch {
|
|
57
64
|
return null;
|
|
58
65
|
}
|
|
66
|
+
if (typeof value !== "object" || value === null)
|
|
67
|
+
return null;
|
|
68
|
+
const { apiKey, apiUrl, clients } = value;
|
|
69
|
+
const top = parseLogin({ apiKey, apiUrl });
|
|
70
|
+
if (top === null && (apiKey !== undefined || apiUrl !== undefined))
|
|
71
|
+
return null;
|
|
72
|
+
// No prototype, so a client named like an Object method (`constructor`) finds nothing rather than a function.
|
|
73
|
+
const named = Object.create(null);
|
|
74
|
+
if (clients !== undefined) {
|
|
75
|
+
if (typeof clients !== "object" || clients === null || Array.isArray(clients))
|
|
76
|
+
return null;
|
|
77
|
+
for (const [name, entry] of Object.entries(clients)) {
|
|
78
|
+
const login = parseLogin(entry);
|
|
79
|
+
if (!CLIENT_NAME.test(name) || login === null)
|
|
80
|
+
return null;
|
|
81
|
+
named[name] = login;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (top === null && Object.keys(named).length === 0)
|
|
85
|
+
return null;
|
|
86
|
+
return { default: top, clients: named };
|
|
59
87
|
}
|
|
60
|
-
|
|
61
|
-
const
|
|
88
|
+
function parseLogin(value) {
|
|
89
|
+
const { apiKey, apiUrl } = (value ?? {});
|
|
90
|
+
return typeof apiKey === "string" && typeof apiUrl === "string" ? { apiKey, apiUrl } : null;
|
|
91
|
+
}
|
|
92
|
+
/** Saves `credentials` as the default login, or as the named client's, keeping every other saved login. */
|
|
93
|
+
export async function writeCredentials(credentials, options = {}) {
|
|
94
|
+
const path = credentialsPath(options.configHome);
|
|
62
95
|
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
63
|
-
await
|
|
96
|
+
const saved = (await readSavedLogins(options.configHome).catch(ignoreUnreadable)) ?? { default: null, clients: {} };
|
|
97
|
+
const next = options.client === undefined ? { ...saved, default: credentials } : { ...saved, clients: { ...saved.clients, [options.client]: credentials } };
|
|
98
|
+
await writeSavedLogins(path, next);
|
|
64
99
|
return path;
|
|
65
100
|
}
|
|
66
|
-
|
|
67
|
-
|
|
101
|
+
/** Removes the default login, or the named client's, keeping the rest; the file goes once nothing is left in it. */
|
|
102
|
+
export async function removeCredentials(client) {
|
|
103
|
+
const path = credentialsPath();
|
|
104
|
+
const saved = await readSavedLogins().catch(ignoreUnreadable);
|
|
105
|
+
const clients = { ...saved?.clients };
|
|
106
|
+
if (client !== undefined)
|
|
107
|
+
delete clients[client];
|
|
108
|
+
const next = { default: client === undefined ? null : (saved?.default ?? null), clients };
|
|
109
|
+
if (next.default === null && Object.keys(clients).length === 0)
|
|
110
|
+
return rm(path, { force: true });
|
|
111
|
+
await writeSavedLogins(path, next);
|
|
112
|
+
}
|
|
113
|
+
async function writeSavedLogins(path, saved) {
|
|
114
|
+
const file = { ...saved.default, ...(Object.keys(saved.clients).length > 0 && { clients: saved.clients }) };
|
|
115
|
+
await writeFileAtomic(path, `${JSON.stringify(file, null, 2)}\n`, 0o600);
|
|
116
|
+
}
|
|
117
|
+
/** An unreadable file is replaced by the next login, as nothing in it can be used. */
|
|
118
|
+
function ignoreUnreadable(error) {
|
|
119
|
+
if (error instanceof UnreadableCredentialsError)
|
|
120
|
+
return null;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
/** The API key env var beats `--client`, which beats the nearest client file above `cwd`, which beats the default login. */
|
|
124
|
+
export async function chooseClient(clientFlag, cwd = process.cwd()) {
|
|
125
|
+
const flagged = clientFlag === undefined ? undefined : parseClientName(clientFlag);
|
|
126
|
+
if (process.env[`${envPrefix}_API_KEY`])
|
|
127
|
+
return { source: "env" };
|
|
128
|
+
if (flagged !== undefined)
|
|
129
|
+
return { source: "flag", client: flagged };
|
|
130
|
+
// A scheduled hand's run holds only its own default key, and must not be steered by files outside its folder.
|
|
131
|
+
if (process.env[RUN_ROOT_ENV])
|
|
132
|
+
return { source: "default" };
|
|
133
|
+
const found = await findClientFile(cwd);
|
|
134
|
+
return found ? { source: "folder", ...found } : { source: "default" };
|
|
135
|
+
}
|
|
136
|
+
async function findClientFile(from) {
|
|
137
|
+
for (let dir = resolve(from);; dir = dirname(dir)) {
|
|
138
|
+
const path = join(dir, CLIENT_FILE);
|
|
139
|
+
const text = await readFile(path, "utf8").catch((error) => {
|
|
140
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
141
|
+
return null;
|
|
142
|
+
throw new CliError(`Could not read the client file at ${path} (${error.code ?? error.message}), so the client to use is unknown.`, exitCodes.usage);
|
|
143
|
+
});
|
|
144
|
+
if (text !== null)
|
|
145
|
+
return { client: parseClientFile(text, path), path };
|
|
146
|
+
if (dirname(dir) === dir)
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
68
149
|
}
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
150
|
+
function parseClientFile(text, path) {
|
|
151
|
+
const malformed = new CliError(`The client file at ${path} is malformed. It should hold {"client": "<name>"}, a lowercase name with letters, digits and dashes.`, exitCodes.usage);
|
|
152
|
+
let value;
|
|
153
|
+
try {
|
|
154
|
+
value = JSON.parse(text);
|
|
74
155
|
}
|
|
75
|
-
|
|
156
|
+
catch {
|
|
157
|
+
throw malformed;
|
|
158
|
+
}
|
|
159
|
+
const client = value?.client;
|
|
160
|
+
if (typeof client !== "string" || !CLIENT_NAME.test(client))
|
|
161
|
+
throw malformed;
|
|
162
|
+
return client;
|
|
163
|
+
}
|
|
164
|
+
export async function resolveLogin(options) {
|
|
165
|
+
const choice = await chooseClient(options.client);
|
|
166
|
+
const saved = await readSavedLogins();
|
|
167
|
+
if (choice.source === "env") {
|
|
168
|
+
return { choice, connection: { apiUrl: resolveApiUrl(options.apiUrl, saved?.default ?? null), apiKey: process.env[`${envPrefix}_API_KEY`] } };
|
|
169
|
+
}
|
|
170
|
+
if (choice.source === "default") {
|
|
171
|
+
if (!saved?.default)
|
|
172
|
+
throw new CliError(`Not logged in. Run \`${brand.slug} login\` or set ${envPrefix}_API_KEY.`, exitCodes.auth);
|
|
173
|
+
return { choice, connection: { apiUrl: resolveApiUrl(options.apiUrl, saved.default), apiKey: saved.default.apiKey } };
|
|
174
|
+
}
|
|
175
|
+
const login = saved?.clients[choice.client];
|
|
176
|
+
const relogin = `\`${brand.slug} login --client ${choice.client}\``;
|
|
177
|
+
if (!login) {
|
|
178
|
+
const why = choice.source === "folder" ? `, named by ${choice.path}` : "";
|
|
179
|
+
throw new CliError(`No saved key for client ${choice.client}${why}. Run ${relogin}.`, exitCodes.auth);
|
|
180
|
+
}
|
|
181
|
+
const apiUrl = resolveApiUrl(options.apiUrl, login);
|
|
182
|
+
return { choice, connection: { apiUrl, apiKey: login.apiKey, rejected: `The API key for client ${choice.client} was rejected by ${apiUrl}. Run ${relogin} with a valid key.` } };
|
|
183
|
+
}
|
|
184
|
+
export async function resolveConnection(options) {
|
|
185
|
+
return (await resolveLogin(options)).connection;
|
|
186
|
+
}
|
|
187
|
+
/** The API URL the chosen login points at, for connections that carry another key; a missing or unreadable login is no error. */
|
|
188
|
+
export async function chosenApiUrl(options) {
|
|
189
|
+
const choice = await chooseClient(options.client);
|
|
190
|
+
const saved = await readSavedLogins().catch(ignoreUnreadable);
|
|
191
|
+
const login = choice.source === "flag" || choice.source === "folder" ? saved?.clients[choice.client] : saved?.default;
|
|
192
|
+
return resolveApiUrl(options.apiUrl, login ?? null);
|
|
76
193
|
}
|
|
77
194
|
export function resolveApiUrl(apiUrlFlag, stored) {
|
|
78
195
|
return (apiUrlFlag || process.env[`${envPrefix}_API_URL`] || stored?.apiUrl || brand.apiUrl).replace(/\/+$/, "");
|
package/dist/guide.js
CHANGED
|
@@ -23,8 +23,8 @@ export const guide = `# ${brand.name} CLI
|
|
|
23
23
|
|
|
24
24
|
## Setup
|
|
25
25
|
|
|
26
|
-
1. Check the login: \`${cli} whoami\`. It prints the workspace name, slug, and id,
|
|
27
|
-
2. If it exits with code 3, you are not logged in. Ask the user to run \`${cli} login\` in their own terminal. It prompts for the key without showing it. Never ask for the key in chat and never put it on a command line, where it lands in shell history and logs. In CI, the key comes from the \`${envPrefix}_API_KEY\` environment variable.
|
|
26
|
+
1. Check the login: \`${cli} whoami\`. It prints the workspace name, slug, and id, whether the key has library access, and which client's key is in use and why.
|
|
27
|
+
2. If it exits with code 3, you are not logged in. Ask the user to run the \`${cli} login\` command the error names (\`${cli} login --client <name>\` for a named client) in their own terminal. It prompts for the key without showing it. Never ask for the key in chat and never put it on a command line, where it lands in shell history and logs. In CI, the key comes from the \`${envPrefix}_API_KEY\` environment variable.
|
|
28
28
|
|
|
29
29
|
## Start with the data guide
|
|
30
30
|
|
|
@@ -40,7 +40,7 @@ ${brand.name} does not build pages; it hands whoever builds one what is already
|
|
|
40
40
|
|
|
41
41
|
## Which Facebook ads and pages won: winners and flops
|
|
42
42
|
|
|
43
|
-
\`${cli} winners\` says which Facebook ads won and which flopped, for the dates asked for and for the 30 days ending on their last day, judged on
|
|
43
|
+
\`${cli} winners\` says which Facebook ads won and which flopped, for the dates asked for and for the 30 days ending on their last day, judged on the client's goal. Each client has an ordered list of goals, and winners uses the first one whose data is connected: \`sales\` (first-touch sales from every connected payment source: each sale goes to the Facebook ad that first brought the buyer in, and payments typed into GoHighLevel by hand never count toward an ad), \`sales:triple_whale\` (the orders Triple Whale credits to each ad), \`booked_calls\` (calls booked by someone whose first touch was the ad), \`meta_results\` (Facebook's own count of the result types set on the Meta source, such as applications, \`submit_application_website\`) or \`first_leads\`. The default list is Triple Whale's orders, then first-touch sales, then first leads, which is how winners judged before goals could be set. \`${cli} winners goals\` shows the list, whether each goal has data and which one is used now; \`${cli} winners goals set booked_calls first_leads\` sets it, and \`${cli} winners goals reset\` goes back to the default. To judge on Stripe sales, connect Stripe, then put \`sales\` first. The report starts with a sentence saying which goal it judged on, and in the JSON every "sales" field (\`numbers.sales\`, \`costPerSale\`, \`accountCostPerSale\`, \`adSales\`, \`judgedOn: "sales"\`) holds that goal, named in \`goal\`, and every "signups" field the fallback goal, named in \`fallbackGoal\`. An ad with none of the goal that has spent less than twice the account's cost per one is too new to judge on it, so it is judged on the fallback goal, the next connected goal of another kind, and listed apart. Landing pages and ad-and-page pairs are always judged on first-touch sales and first leads. Each ad says in a sentence why it is on its list. Each currency also says how many of its Facebook sales could be tied to a specific ad (\`adSales\` of \`facebookSales\` in the JSON): a sale whose link names no single ad counts toward Facebook but toward no ad. It judges the landing pages the ads sent people to the same way, and each ad on each page as an ad-and-page pair: a page or pair counts only once the page reader has read the page and it got at least 100 visits in the dates, and pairs are listed only for an ad that sent people to two or more pages, or a page that got people from two or more ads, each naming what the same ad cost on its other pages. \`--week <YYYY-MM-DD>\` picks any day of a Monday-to-Sunday week (default: the latest complete week), \`--days <n>\` the last n complete days ending yesterday, and \`--from <YYYY-MM-DD> --to <YYYY-MM-DD>\` any dates; use one of them, at most 90 days. Check as often as suits the client, daily or every few days: a short window holds few sales, so more ads are judged on first leads, and the report says so. \`--top <n>\` how many ads, pages or pairs each list shows (default 5, at most 20), \`--ad-format image|video|carousel\` ranks only ads with that format tag (run \`${cli} ads tag-formats\` first; the account's cost per sale still counts every ad), and \`--format json\` gives the report as data.
|
|
44
44
|
|
|
45
45
|
## What winners and flops taught: lessons
|
|
46
46
|
|
|
@@ -54,7 +54,7 @@ Write one as JSON, \`{"text": "...", "dimension": "hook", "evidence": [{"ad": "<
|
|
|
54
54
|
|
|
55
55
|
## The weekly loop: a sample for an orchestrator agent
|
|
56
56
|
|
|
57
|
-
The \`weekly-loop\` playbook (\`${cli} skill install weekly-loop\` installs it as the \`${WEEKLY_LOOP_SKILL_NAME}\` skill; \`${cli} docs weekly-loop\` prints it) is a sample to borrow from, not a schedule, for a local agent that runs a client's Facebook ads with its owner: its steps can run daily, several times a week or weekly, and the winners report covers whatever dates are asked for. It names the command and the MCP tool for each step: the winners and flops report, lessons, fresh customer words, the brief, the offers and funnels map, making ads (Exodus, paid tools or the agent's own skills), and the results of what was published, read back by Adzara's labels. Judge winners by ${brand.name}'s
|
|
57
|
+
The \`weekly-loop\` playbook (\`${cli} skill install weekly-loop\` installs it as the \`${WEEKLY_LOOP_SKILL_NAME}\` skill; \`${cli} docs weekly-loop\` prints it) is a sample to borrow from, not a schedule, for a local agent that runs a client's Facebook ads with its owner: its steps can run daily, several times a week or weekly, and the winners report covers whatever dates are asked for. It names the command and the MCP tool for each step: the winners and flops report, lessons, fresh customer words, the brief, the offers and funnels map, making ads (Exodus, paid tools or the agent's own skills), and the results of what was published, read back by Adzara's labels. Judge winners by ${brand.name}'s Winners and flops report, which judges each client on its goal (\`${cli} winners\`); Adzara's results are Meta's count. Approvals and decisions happen in the agent's own session with the owner; ${brand.name} records none. Ads are published and changed through Adzara, never through ${brand.name}.
|
|
58
58
|
|
|
59
59
|
## Trying one change: funnel tests
|
|
60
60
|
|
|
@@ -291,9 +291,9 @@ A scheduled hand's run acts as its skill's agent, the way the Facebook ads agent
|
|
|
291
291
|
## Commands
|
|
292
292
|
|
|
293
293
|
\`\`\`
|
|
294
|
-
${cli} login
|
|
295
|
-
${cli} logout
|
|
296
|
-
${cli} whoami show the current workspace and
|
|
294
|
+
${cli} login [--client <name>] validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it, as the default or under a client's name
|
|
295
|
+
${cli} logout [--client <name>] delete the default saved login, or one client's
|
|
296
|
+
${cli} whoami show the current workspace, library access, and which client's key is in use and why
|
|
297
297
|
${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] [--hook-kind <v> ...] short, cited snippets from the know-how libraries (keys with library access)
|
|
298
298
|
${cli} knowledge tag <entry-id> --hook-kind <v> --confidence <c> tag a library entry with the marketing grammar (or --file tags.json)
|
|
299
299
|
${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
|
|
@@ -301,7 +301,8 @@ ${cli} brief [--from <date>] [--to <date>] [--channel <channel>] [--campaign <id
|
|
|
301
301
|
${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
|
|
302
302
|
${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
|
|
303
303
|
${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)
|
|
304
|
-
${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
|
|
304
|
+
${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
|
|
305
|
+
${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
|
|
305
306
|
${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
|
|
306
307
|
${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
|
|
307
308
|
${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin); --out <file.csv | file.ndjson | file.json> saves the rows; add --all for every row
|