@wrangled/cli 0.2.0
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 +192 -0
- package/dist/brand.js +9 -0
- package/dist/files.js +66 -0
- package/dist/guide.js +221 -0
- package/dist/main.js +2151 -0
- package/dist/playbooks.js +512 -0
- package/dist/pull.js +209 -0
- package/dist/runner/claim.js +42 -0
- package/dist/runner/claude.js +87 -0
- package/dist/runner/codex.js +167 -0
- package/dist/runner/harness.js +65 -0
- package/dist/runner/harnesses.js +10 -0
- package/dist/runner/redact.js +56 -0
- package/dist/runner/run.js +204 -0
- package/dist/skill-zip.js +189 -0
- package/dist/skills.js +131 -0
- package/package.json +34 -0
package/dist/api.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { brand, envPrefix } from "./brand.js";
|
|
5
|
+
import { writeFileAtomic } from "./files.js";
|
|
6
|
+
export const exitCodes = { ok: 0, error: 1, usage: 2, auth: 3, interrupted: 130 };
|
|
7
|
+
export class CliError extends Error {
|
|
8
|
+
exitCode;
|
|
9
|
+
constructor(message, exitCode) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.exitCode = exitCode;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class UnreadableCredentialsError extends CliError {
|
|
15
|
+
constructor(path) {
|
|
16
|
+
super(`The saved credentials at ${path} are unreadable. Run \`${brand.slug} login\` again.`, exitCodes.auth);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Copies of the server's IMPORT_LIMITS and import name rule; src/lib/cli-consistency.test.ts keeps them in step. */
|
|
20
|
+
export const IMPORT_LIMITS = { bytes: 50 * 1024 * 1024, rows: 1_000_000, columns: 500 };
|
|
21
|
+
export const IMPORT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
|
|
22
|
+
/** `configHome` defaults to $XDG_CONFIG_HOME, or ~/.config. */
|
|
23
|
+
export function credentialsPath(configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")) {
|
|
24
|
+
return join(configHome, brand.slug, "credentials.json");
|
|
25
|
+
}
|
|
26
|
+
export async function readCredentials() {
|
|
27
|
+
const path = credentialsPath();
|
|
28
|
+
let text;
|
|
29
|
+
try {
|
|
30
|
+
text = await readFile(path, "utf8");
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code === "ENOENT")
|
|
34
|
+
return null;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const credentials = parseCredentials(text);
|
|
38
|
+
if (!credentials)
|
|
39
|
+
throw new UnreadableCredentialsError(path);
|
|
40
|
+
return credentials;
|
|
41
|
+
}
|
|
42
|
+
function parseCredentials(text) {
|
|
43
|
+
try {
|
|
44
|
+
const value = JSON.parse(text);
|
|
45
|
+
if (typeof value?.apiKey !== "string" || typeof value.apiUrl !== "string")
|
|
46
|
+
return null;
|
|
47
|
+
return { apiKey: value.apiKey, apiUrl: value.apiUrl };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function writeCredentials(credentials, configHome) {
|
|
54
|
+
const path = credentialsPath(configHome);
|
|
55
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
56
|
+
await writeFileAtomic(path, `${JSON.stringify(credentials, null, 2)}\n`, 0o600);
|
|
57
|
+
return path;
|
|
58
|
+
}
|
|
59
|
+
export async function removeCredentials() {
|
|
60
|
+
await rm(credentialsPath(), { force: true });
|
|
61
|
+
}
|
|
62
|
+
export async function resolveConnection(apiUrlFlag) {
|
|
63
|
+
const stored = await readCredentials();
|
|
64
|
+
const apiKey = process.env[`${envPrefix}_API_KEY`] || stored?.apiKey;
|
|
65
|
+
if (!apiKey) {
|
|
66
|
+
throw new CliError(`Not logged in. Run \`${brand.slug} login\` or set ${envPrefix}_API_KEY.`, exitCodes.auth);
|
|
67
|
+
}
|
|
68
|
+
return { apiUrl: resolveApiUrl(apiUrlFlag, stored), apiKey };
|
|
69
|
+
}
|
|
70
|
+
export function resolveApiUrl(apiUrlFlag, stored) {
|
|
71
|
+
return (apiUrlFlag || process.env[`${envPrefix}_API_URL`] || stored?.apiUrl || brand.apiUrl).replace(/\/+$/, "");
|
|
72
|
+
}
|
|
73
|
+
export async function api(connection, path, body, method) {
|
|
74
|
+
return JSON.parse(await apiText(connection, path, body, method));
|
|
75
|
+
}
|
|
76
|
+
export async function apiText(connection, path, body, method) {
|
|
77
|
+
return send(connection, path, method ?? (body === undefined ? "GET" : "POST"), "application/json", body === undefined ? undefined : JSON.stringify(body));
|
|
78
|
+
}
|
|
79
|
+
/** POSTs `body` as it is, with its own content type, and parses the JSON answer. */
|
|
80
|
+
export async function apiUpload(connection, path, body, contentType) {
|
|
81
|
+
return JSON.parse(await send(connection, path, "POST", contentType, body));
|
|
82
|
+
}
|
|
83
|
+
/** A non-2xx answer other than 401; `code` is the API's error code when the body carried one. */
|
|
84
|
+
export class ApiError extends CliError {
|
|
85
|
+
status;
|
|
86
|
+
code;
|
|
87
|
+
hint;
|
|
88
|
+
constructor(message, status, code, hint) {
|
|
89
|
+
super(message, exitCodes.error);
|
|
90
|
+
this.status = status;
|
|
91
|
+
this.code = code;
|
|
92
|
+
this.hint = hint;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function send(connection, path, method, contentType, body) {
|
|
96
|
+
const response = await request(connection, path, { method, headers: { "content-type": contentType }, body });
|
|
97
|
+
if (!response.ok)
|
|
98
|
+
throw new ApiError(`HTTP ${response.status} from ${path}`, response.status, undefined);
|
|
99
|
+
return response.text();
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Fetches a stored asset. The API answers with a redirect to a short-lived signed storage URL, which is
|
|
103
|
+
* fetched without the workspace key. The signed URL carries its own token, so no message ever includes it.
|
|
104
|
+
*/
|
|
105
|
+
export async function apiDownload(connection, path) {
|
|
106
|
+
const redirect = await request(connection, path, { method: "GET", redirect: "manual" });
|
|
107
|
+
const location = redirect.headers.get("location");
|
|
108
|
+
if (redirect.status < 300 || redirect.status >= 400 || !location) {
|
|
109
|
+
throw new CliError(`Expected a redirect to the file from ${path}, got HTTP ${redirect.status}.`, exitCodes.error);
|
|
110
|
+
}
|
|
111
|
+
const signed = new URL(location, connection.apiUrl);
|
|
112
|
+
let response;
|
|
113
|
+
try {
|
|
114
|
+
response = await fetch(signed);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
const code = error.cause?.code ?? "network error";
|
|
118
|
+
throw new CliError(`Cannot reach media storage at ${signed.origin}: ${code}`, exitCodes.error);
|
|
119
|
+
}
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
await response.body?.cancel();
|
|
122
|
+
throw new CliError(`Media storage answered HTTP ${response.status} for ${path}. Try again.`, exitCodes.error);
|
|
123
|
+
}
|
|
124
|
+
return { bytes: new Uint8Array(await response.arrayBuffer()), contentType: response.headers.get("content-type") };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* PUTs a file to a signed storage URL the API handed out, without the workspace key. The URL carries its own token,
|
|
128
|
+
* so no message ever includes it.
|
|
129
|
+
*/
|
|
130
|
+
export async function putSigned(signedUrl, body, contentType) {
|
|
131
|
+
const signed = new URL(signedUrl);
|
|
132
|
+
let response;
|
|
133
|
+
try {
|
|
134
|
+
response = await fetch(signed, { method: "PUT", headers: { "content-type": contentType }, body });
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
const code = error.cause?.code ?? "network error";
|
|
138
|
+
throw new CliError(`Cannot reach file storage at ${signed.origin}: ${code}`, exitCodes.error);
|
|
139
|
+
}
|
|
140
|
+
await response.body?.cancel();
|
|
141
|
+
if (!response.ok)
|
|
142
|
+
throw new CliError(`File storage answered HTTP ${response.status} to the upload. Try again.`, exitCodes.error);
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* GETs a file from a signed storage URL the API handed out, without any credential of ours. The URL carries its own
|
|
146
|
+
* token, so no message ever includes it.
|
|
147
|
+
*/
|
|
148
|
+
export async function getSigned(signedUrl) {
|
|
149
|
+
const signed = new URL(signedUrl);
|
|
150
|
+
let response;
|
|
151
|
+
try {
|
|
152
|
+
response = await fetch(signed);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const code = error.cause?.code ?? "network error";
|
|
156
|
+
throw new CliError(`Cannot reach file storage at ${signed.origin}: ${code}`, exitCodes.error);
|
|
157
|
+
}
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
await response.body?.cancel();
|
|
160
|
+
throw new CliError(`File storage answered HTTP ${response.status} to the download. Try again.`, exitCodes.error);
|
|
161
|
+
}
|
|
162
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
163
|
+
}
|
|
164
|
+
async function request(connection, path, init) {
|
|
165
|
+
let response;
|
|
166
|
+
try {
|
|
167
|
+
response = await fetch(`${connection.apiUrl}${path}`, {
|
|
168
|
+
...init,
|
|
169
|
+
headers: { authorization: `Bearer ${connection.apiKey}`, ...init.headers },
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
const cause = error.cause?.message ?? error.message;
|
|
174
|
+
throw new CliError(`Cannot reach ${connection.apiUrl}: ${cause}`, exitCodes.error);
|
|
175
|
+
}
|
|
176
|
+
if (response.status === 401) {
|
|
177
|
+
throw new CliError(connection.rejected ?? `The API key was rejected by ${connection.apiUrl}. Run \`${brand.slug} login\` with a valid key.`, exitCodes.auth);
|
|
178
|
+
}
|
|
179
|
+
if (response.status >= 400) {
|
|
180
|
+
const error = parseErrorBody(await response.text());
|
|
181
|
+
throw new ApiError(error ? `${error.code}: ${error.message}` : `HTTP ${response.status} from ${path}`, response.status, error?.code, error?.hint);
|
|
182
|
+
}
|
|
183
|
+
return response;
|
|
184
|
+
}
|
|
185
|
+
function parseErrorBody(text) {
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(text).error;
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
package/dist/brand.js
ADDED
package/dist/files.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { lstat, readlink, realpath, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { CliError, exitCodes } from "./api.js";
|
|
5
|
+
import { envPrefix } from "./brand.js";
|
|
6
|
+
/** Set by the runner to the run's folder; inside a run, the CLI reads and writes files only there. */
|
|
7
|
+
export const RUN_ROOT_ENV = `${envPrefix}_RUN_ROOT`;
|
|
8
|
+
/**
|
|
9
|
+
* Returns `path` unchanged, or throws when a run root is set and the path's real location (symlinks followed) is
|
|
10
|
+
* outside it. A path that does not exist yet is judged by its nearest existing folder.
|
|
11
|
+
*/
|
|
12
|
+
export async function inRunFolder(path) {
|
|
13
|
+
const root = process.env[RUN_ROOT_ENV];
|
|
14
|
+
if (!root)
|
|
15
|
+
return path;
|
|
16
|
+
const realRoot = await realpath(root);
|
|
17
|
+
// A sandbox around the CLI (Codex's) can forbid even looking up a path outside the run; that path is judged as written.
|
|
18
|
+
const realTarget = await realLocation(resolve(path)).catch((error) => {
|
|
19
|
+
if (error.code === "EPERM" || error.code === "EACCES")
|
|
20
|
+
return resolve(path);
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
const rel = relative(realRoot, realTarget);
|
|
24
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
25
|
+
throw new CliError(`${path} is outside this run's folder; inside a run, files can only be read and written there.`, exitCodes.usage);
|
|
26
|
+
}
|
|
27
|
+
return path;
|
|
28
|
+
}
|
|
29
|
+
async function realLocation(path) {
|
|
30
|
+
try {
|
|
31
|
+
return await realpath(path);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
const parent = dirname(path);
|
|
35
|
+
if (error.code !== "ENOENT" || parent === path)
|
|
36
|
+
throw error;
|
|
37
|
+
// A dangling symlink would be written through, so it is judged by where it points.
|
|
38
|
+
const link = await readlink(path).catch(() => null);
|
|
39
|
+
if (link !== null)
|
|
40
|
+
return realLocation(resolve(parent, link));
|
|
41
|
+
return join(await realLocation(parent), basename(path));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function writeFileAtomic(path, content, mode) {
|
|
45
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomBytes(6).toString("hex")}.tmp`);
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(temp, content, { mode, flag: "wx" });
|
|
48
|
+
await rename(temp, path);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
await rm(temp, { force: true });
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export async function firstSymlink(paths) {
|
|
56
|
+
for (const path of paths) {
|
|
57
|
+
const info = await lstat(path).catch((error) => {
|
|
58
|
+
if (error.code === "ENOENT")
|
|
59
|
+
return null;
|
|
60
|
+
throw error;
|
|
61
|
+
});
|
|
62
|
+
if (info?.isSymbolicLink())
|
|
63
|
+
return path;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
package/dist/guide.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { IMPORT_LIMITS } from "./api.js";
|
|
2
|
+
import { brand, envPrefix } from "./brand.js";
|
|
3
|
+
import { PULL_STATE_FILE } from "./pull.js";
|
|
4
|
+
const cli = brand.slug;
|
|
5
|
+
export const guide = `# ${brand.name} CLI
|
|
6
|
+
|
|
7
|
+
\`${cli}\` gives read-only SQL access to one client's marketing data warehouse (ad spend, orders, and other synced sources), runs paid generation and research tools for that client, and keeps the creative ledger. Each API key belongs to exactly one workspace, and every command works only in that workspace.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
1. Check the login: \`${cli} whoami\`. It prints the workspace name, slug, and id.
|
|
12
|
+
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.
|
|
13
|
+
|
|
14
|
+
## Start with the data guide
|
|
15
|
+
|
|
16
|
+
Run \`${cli} guide\` first. It says, in plain words, what this workspace has: each connected source with when it last synced and how many rows it holds over which dates; the content library's ads, emails, text messages, landing pages, videos and posts and how many videos and recordings have transcripts; and what is missing, such as a source that is not connected, a failing sync, or videos and recordings without a transcript yet.
|
|
17
|
+
|
|
18
|
+
## Content: search before SQL
|
|
19
|
+
|
|
20
|
+
To find content, such as "the 10 emails with the best click rate", "our 5 best videos by return on ad spend" or "every landing page that mentions the guarantee", use \`${cli} content search\` before writing SQL on the \`content_\` tables.
|
|
21
|
+
|
|
22
|
+
- \`--text "<words>"\` finds words in the current version's subject, headline and body, or in a video's transcript. Words are stemmed ("guarantee" finds "guaranteed"); use "quotes" for a phrase and -word to leave a word out.
|
|
23
|
+
- \`--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).
|
|
24
|
+
- \`--kind\` (ad, email, sms, landing_page, video, social_post, conversation), \`--platform\` (meta, ghl, klaviyo, roezan, bayengage, google_ads, web, youtube, instagram, 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.
|
|
25
|
+
- \`--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).
|
|
26
|
+
- 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.
|
|
27
|
+
- 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.
|
|
28
|
+
|
|
29
|
+
To read the whole library with your own tools, such as "every email we have" or "every transcript", copy it to a folder: \`${cli} content pull <dir>\`. It writes one Markdown file per item, \`<dir>/<kind>/<SKU or natural key>.md\`: front matter with its id, SKU, kind, platform, origin, name, status, version, when that version was captured, \`removed_at\` (set when the platform no longer has it), its links, its HTML and file storage paths, and its scorecard numbers, one set per currency; then its subject, preview text, headline, body, fields (as JSON) and transcripts. Search the folder with grep or open the files. Run the same command again to fetch only what changed since the last pull (the folder's \`${PULL_STATE_FILE}\` remembers where it stopped); only files whose text changed are rewritten, and the output lists them. \`--kind\`, \`--platform\` and \`--origin\` copy part of the library; \`--media\` also saves each item's files and HTML under \`media/\`; \`--full\` fetches every item again, which brings new scorecard numbers, since numbers change without the item changing. A folder holds one workspace's content; pulling another workspace into it is refused.
|
|
30
|
+
|
|
31
|
+
## Query workflow: schema first
|
|
32
|
+
|
|
33
|
+
1. Run \`${cli} warehouse schema --summary\` before writing any SQL. It lists each table once: whether it is a table or a view, which source fills it, how many columns it has, and a one-line summary. Then run \`${cli} warehouse schema --table <name>\` (repeat for several) or \`--search <words>\` for the full detail of just the tables you need: every column with its Postgres type, nullability, and description. Plain \`${cli} warehouse schema\` prints every column of every table, which on a big warehouse is a lot. Read the descriptions: they say what null means and which metrics do not add up. Use only these names. Never guess a table or column name.
|
|
34
|
+
2. Write one SQL statement in the PostgreSQL dialect. Refer to tables unqualified (\`FROM orders\`). The workspace schema is already on the search path.
|
|
35
|
+
3. Run it: \`${cli} warehouse query "SELECT ..."\`. For long SQL, use \`--file query.sql\` or pipe it on stdin.
|
|
36
|
+
4. Aggregate in SQL (\`GROUP BY\`, \`SUM\`, \`date_trunc\`) and add \`LIMIT\`. Do not pull raw rows to aggregate them yourself.
|
|
37
|
+
5. Before trusting recent or old numbers, run \`${cli} sources list\`. It shows when each source last synced, its last error, and when it syncs next. \`${cli} sources show <id>\` adds each table's row count and date range, the history still to fetch, and the last sync runs.
|
|
38
|
+
|
|
39
|
+
## Funnel, campaign and money reports
|
|
40
|
+
|
|
41
|
+
For "how is the funnel doing?", "which campaigns are working?" and "where does the money come from and go?", start with these three reports before writing SQL. All three cover the last 30 days ending today in the workspace reporting time zone unless \`--from\` and \`--to\` (YYYY-MM-DD, both days included) say otherwise, and at most 400 days.
|
|
42
|
+
|
|
43
|
+
- \`${cli} funnel [--grain day|week|month]\` prints ad spend, leads, booked calls, calls held, sales and net revenue per day, week (starting Monday) or month, with cost per lead, per booked call and per sale and ROAS, then totals for the whole range.
|
|
44
|
+
- \`${cli} campaigns [--channel <channel>] [--limit <n>]\` prints each campaign's spend, leads, booked calls, sales and revenue, best first (most first-touch revenue, then most spend), then totals over every matching campaign. Each lead and sale is credited to the person's first touch and last touch side by side. \`--limit\` is 1 to 200 (default 50); \`--channel\` is meta, google, affiliate, a utm_source, or unknown.
|
|
45
|
+
- \`${cli} money [--grain day|week|month]\` prints money in (gross, refunds, net, orders), ad spend, affiliate payouts, what is left after ad costs and ROAS per period, then totals for the whole range, money in per payment source (stripe, ghl) and the biggest refund days. Refunds come off on the day they were made, not the day of the sale. \`left_after_ad_costs\` is net less ad spend and affiliate payouts; it is not profit. Its \`roas\` is net over ad spend alone, so it differs from \`${cli} funnel\`'s, which divides by all spend including affiliate payouts.
|
|
46
|
+
- 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.
|
|
47
|
+
- 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.
|
|
48
|
+
|
|
49
|
+
## Webhook addresses
|
|
50
|
+
|
|
51
|
+
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. 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.
|
|
52
|
+
|
|
53
|
+
## Limits
|
|
54
|
+
|
|
55
|
+
- One statement per call, and it must return rows: \`SELECT\`, \`VALUES\`, or \`WITH ... SELECT\`. Anything else (\`INSERT\`, \`UPDATE\`, \`DELETE\`, DDL, \`SET\`, \`COPY\`) fails.
|
|
56
|
+
- 15-second statement timeout.
|
|
57
|
+
- Results stop at 1000 rows or about 5 MB. In JSON output, \`truncated\` is \`"row_limit"\` or \`"byte_limit"\` when a limit was hit, otherwise \`null\`.
|
|
58
|
+
- In JSON output, \`columns\` lists each column's name and Postgres type. \`numeric\` and \`bigint\` values are JSON numbers with their exact digits, so parse them with a big-number-safe parser when precision matters. Dates are \`YYYY-MM-DD\` strings.
|
|
59
|
+
- Every output column needs a unique name. Alias duplicates (\`a.id AS order_id\`).
|
|
60
|
+
|
|
61
|
+
## Importing a CSV file
|
|
62
|
+
|
|
63
|
+
\`${cli} import csv <file> --table <name>\` loads a spreadsheet export into the warehouse as the table \`import_<name>\`, next to the synced data; query it with \`${cli} warehouse query\` like any other table. The name uses lowercase letters, digits and underscores, starts with a letter, and is at most 41 characters. Importing again under the same name replaces the table in one step: a query running meanwhile sees the old table or the new one, never neither, and a failed import leaves the old table as it was. \`${cli} import remove --table <name> --yes\` drops the table and removes the import from the Sources page; a name with no import fails with \`not_found\`.
|
|
64
|
+
|
|
65
|
+
- The file is UTF-8 CSV with a header row. Column names come from the headers, lowercased, with each run of other characters turned into \`_\`. Blank values are NULL.
|
|
66
|
+
- Each column gets the first type every value fits: \`boolean\` (true, false, yes, no), \`bigint\`, \`numeric\`, \`date\` (2026-01-31 or 1/31/2026), \`timestamptz\` (ISO 8601), else \`text\`. Numbers may carry \`$\`, thousands commas and, for \`numeric\`, a trailing \`%\`, which are dropped. A number with a leading zero, like a ZIP code, stays text.
|
|
67
|
+
- 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.
|
|
68
|
+
- 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 }\`.
|
|
69
|
+
|
|
70
|
+
## Making ads: tools and the creative ledger
|
|
71
|
+
|
|
72
|
+
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.
|
|
73
|
+
2. \`${cli} tools run kie:nano-banana --input '{"prompt": "...", "aspectRatio": "1:1"}' --idempotency-key <key>\` starts a run and waits for it (up to \`--wait\` seconds, default 300). Tools cost money. Always pass an idempotency key, and reuse it when you retry, so a retry returns the same run instead of paying twice.
|
|
74
|
+
3. A run that is still going prints its id. Check it with \`${cli} tools get <run-id>\`. Image results list an \`asset\` link. Save the file with \`${cli} creatives download <asset-link>\`, or fetch the link with your API key as a bearer token; it redirects to a copy that works for 10 minutes.
|
|
75
|
+
4. Record a succeeded run in the ledger with its lineage: \`${cli} creatives create --run <run-id> --lineage '{"persona": "...", "painPoint": "...", "desiredOutcome": "...", "angle": "...", "sourceAd": {"metaAdId": "..."}}'\`. Each output gets a SKU such as \`DLC-000123\`. The SKU goes in the Meta ad name.
|
|
76
|
+
5. \`${cli} creatives list\` and \`${cli} creatives show <sku>\` read the ledger. \`${cli} creatives download <sku>\` saves a creative's image or video as \`<sku>.<ext>\` in the current folder; \`--out <path>\` names the file or a folder, and \`--open\` opens it. It also takes an asset path or link, such as a \`meta_ad_creatives\` \`image_path\`. \`--format json\` prints \`{ path, bytes, contentType }\`. Copy has no file; its text is in \`${cli} creatives show\`. For performance, query the \`creatives\` table or the \`creative_performance_daily\` view with \`${cli} warehouse query\`.
|
|
77
|
+
|
|
78
|
+
## Landing pages
|
|
79
|
+
|
|
80
|
+
The warehouse's content library keeps each landing page's words in order, read about once a day: one \`landing_page\` item per page in \`content_items\`, its versions in \`content_versions\` (\`fields->'blocks'\` holds the headings, paragraphs, lists, buttons, image descriptions and form field labels), and the list of pages with when each was last read in \`content_pages\`. Pages come from Meta ad links and GA4 landing pages. Add any other page with \`${cli} content add-page <url>\`: it is read at once, and the output shows its key, whether it could be read (\`ok\`, \`not_found\`, \`blocked\` by robots.txt, \`failed\`, \`not_html\`), and its version. A link that redirects, such as a link shortener, is recorded under the page it leads to.
|
|
81
|
+
|
|
82
|
+
## Making content: a SKU and a record first
|
|
83
|
+
|
|
84
|
+
Save anything you make before it goes anywhere: an ad written without a tool, an email, a text message, a landing page, a video or a social post. \`${cli} content create --kind email --name "Spring sale" --subject "..." --body @email.txt --note "first draft"\` gives it the workspace's next SKU (the same counter as creatives), status draft, and version 1, recorded as made by your API key, or by your agent run with \`--run <agent-run-id>\`. Kinds are \`ad\`, \`email\`, \`sms\`, \`landing_page\`, \`video\` and \`social_post\`. \`--body\` and \`--html\` take text or \`@file\`; \`--file <path>\` (repeatable) uploads an image, video or audio file of up to 50 MB (HTML stays at most 3 MB, and a file that does not fit beside it in one 3 MB request goes to storage first, on its own); \`--tool-run <id>\` (repeatable) links the tool runs it was made with; \`--fields\` takes JSON for structure such as a call to action.
|
|
85
|
+
|
|
86
|
+
- What each kind needs: an ad, body or a file; an email, a subject and a body or HTML; a text message, a body of at most 1600 characters and only image files; a landing page, HTML or body; a video, a video file or a tool run; a social post, body or a file.
|
|
87
|
+
- \`${cli} content edit <sku> --body @v2.txt --note "shorter"\` saves a new version with only what you change; the rest stays. An edit that changes nothing saves no version. A retired item cannot be edited.
|
|
88
|
+
- \`${cli} content status <sku> approved\` moves it along the same steps as creatives. Launching needs where it went and that platform's id: \`${cli} content status <sku> launched --platform ghl --external-id <id>\`.
|
|
89
|
+
- \`${cli} content show <sku>\` prints the current version. Every version is in the \`content_versions\` table for \`${cli} warehouse query\`.
|
|
90
|
+
- \`${cli} content diff <sku | item-id>\` shows what changed from the version before the current one to the current one: each version's time, who made it and its note, then the subject, preview or headline before and after, the body's lines with \`-\` before each removed line and \`+\` before each added one, links and ad copy added or removed, and files added or removed. \`--from <n> --to <n>\` compares any two versions; \`--format json\` gives the same as data.
|
|
91
|
+
- Propose a made item to a person with \`${cli} proposals create\` (kind \`creative\`, its SKU).
|
|
92
|
+
|
|
93
|
+
### Publishing
|
|
94
|
+
|
|
95
|
+
\`${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 helper'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 helper 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.
|
|
96
|
+
|
|
97
|
+
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>"}'\`. 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 helper'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 helper, 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.
|
|
98
|
+
|
|
99
|
+
When you send something yourself instead, record it in ${brand.name} before anything is sent, so the record never depends on the platform remembering it:
|
|
100
|
+
|
|
101
|
+
1. \`${cli} content publish-record <sku> --file publication.json\` saves exactly what you are about to send. The file holds \`platform\` (\`meta\`, \`ghl\`, \`klaviyo\`, \`roezan\`, \`youtube\`, \`instagram\` or \`web\`), \`objectType\` as the syncs name it (\`ad\` or \`creative\` on Meta; \`email_step\`, \`email_blast\`, \`workflow\` or \`campaign\` on GoHighLevel; \`campaign\`, \`campaign_message\`, \`flow\` or \`flow_message\` on Klaviyo; \`broadcast\` on Roezan; \`video\` on YouTube; \`media\` on Instagram), \`request\` (the body you will send), an \`idempotencyKey\` of your own, and optionally \`accountId\`, \`version\` and \`runId\`. Secrets are removed before it is stored. Recording the same key again returns the same publication.
|
|
102
|
+
2. \`${cli} content publish-finish <publication-id> --status sending\` immediately before the platform call.
|
|
103
|
+
3. Right after, \`${cli} content publish-finish <publication-id> --status published --input '{"externalId": "<platform id>"}'\`, adding \`links\` for other ids (such as \`[{"objectType": "creative", "externalId": "..."}]\`) and \`response\`; or \`--status failed --input '{"error": "..."}'\`. Published ids join the item to its results.
|
|
104
|
+
|
|
105
|
+
A publication still \`sending\` after 15 minutes reads \`unknown\`: the send may or may not have happened. Never send it again; check the platform, then finish it as \`published\` or \`failed\`. Unless the publication was recorded with the \`runId\` of a helper in live mode, ${brand.name} refuses \`sending\`, \`published\` and \`failed\` for Meta: finish the record as \`not_sent_practice\` instead. \`${cli} content show <sku>\` lists each publication, and the \`content_publications\` table holds them all.
|
|
106
|
+
|
|
107
|
+
When a sync later brings back something made here, found by a published id, an id already linked, or the SKU in its name, it is recorded on the made item, not as a new discovered one. A copy edited on the platform becomes a new version captured by the sync.
|
|
108
|
+
|
|
109
|
+
### Tracking links: the SKU on every link
|
|
110
|
+
|
|
111
|
+
Every link you publish, in an ad, email, text message or page, carries its item's SKU, so leads and sales come back to the exact item. Never write the tags by hand. \`${cli} content track-link <sku> <url> --channel email --campaign <id>\` prints the link tagged with \`utm_source\` and \`utm_medium\` for the channel (\`facebook\` and \`paid\`, \`email\` and \`email\`, \`sms\` and \`sms\`, or \`web\` and \`referral\`, the default), \`utm_campaign\` when you pass \`--campaign\`, and \`utm_content=<SKU>\`. Tags the link already has are replaced; the rest of the link is kept as it was. It takes only https links, and only the SKU of an item that exists in this workspace (made with \`${cli} content create\` or recorded with \`${cli} creatives create\`).
|
|
112
|
+
|
|
113
|
+
A Meta ad's name starts with its SKU: \`<SKU> | <readable name>\`, at most 400 characters. Its creative's \`url_tags\` is exactly \`utm_source=facebook&utm_medium=paid&utm_campaign={{campaign.id}}&utm_term={{adset.id}}&utm_content=<SKU>&ad_id={{ad.id}}&campaign_id={{campaign.id}}\`; Meta fills in the \`{{...}}\` parts. Code in ${brand.name} itself uses \`tagUrl\`, \`metaUrlTags\` and \`adName\` from \`src/lib/content/tracking.ts\`.
|
|
114
|
+
|
|
115
|
+
The \`touches\` view reads the SKU back into its \`sku\` column, and \`item_results_daily\` counts leads, booked calls and sales per SKU per day, by first and last touch.
|
|
116
|
+
|
|
117
|
+
## Launch plans
|
|
118
|
+
|
|
119
|
+
A launch plan writes down one campaign push before you make things for it, and keeps those things together: what the push is for, where it runs, the landing page its ads send people to, and the saved items that belong to it. Nothing in a plan is sent anywhere.
|
|
120
|
+
|
|
121
|
+
1. \`${cli} plans create --name "P1 cold, guarantee page" --goal "Five new static ads" --channel meta --target '{"account_id": "act_1", "campaign_id": "<id>", "adset_id": "<id>"}' --landing-url https://example.com/guarantee\` makes a draft plan and prints its id. Channels are \`meta\`, \`ghl\`, \`klaviyo\` and \`web\`. A meta plan's target uses ids from \`meta_campaigns\` and \`meta_adsets\`, never names: \`adset_id\` adds the ads to that ad set, \`template_adset_id\` instead copies that ad set for the push. The campaign and ad set must be synced and belong together, and with \`--run <agent-run-id>\` they must be among the campaigns your agent may touch. \`--landing <sku>\` names a landing page made here instead of an address.
|
|
122
|
+
2. \`${cli} content create --kind ad ... --plan <plan-id>\` saves a new item straight into the plan. \`${cli} plans add <plan-id> <sku>\` adds one already saved; an item may sit in several plans. The role defaults from the item's kind (\`ad\`, \`email\`, \`sms\`, \`landing_page\`, \`video\`, or \`post\` for a social post); \`--role copy\` or \`--role image\` says otherwise, and \`--position <n>\` sets its place. \`${cli} plans remove <plan-id> <sku>\` takes it out.
|
|
123
|
+
3. \`${cli} plans status <plan-id> ready\` moves the plan one step along \`draft\`, \`ready\`, \`publishing\`, \`live\`, \`done\`. \`cancelled\` ends a plan that is not done. A done or cancelled plan cannot be edited.
|
|
124
|
+
4. \`${cli} plans show <plan-id>\` prints the plan and its items in order. Every plan is in the \`content_launch_plans\` and \`content_launch_plan_items\` tables for \`${cli} warehouse query\`.
|
|
125
|
+
|
|
126
|
+
## The campaign trail
|
|
127
|
+
|
|
128
|
+
\`${cli} trail <plan-id>\` follows a plan's items from how they were made to where they ran and how they did; \`${cli} trail <sku>\` does it for one item. For each item it shows the paid tool runs it was made from (the tool, the prompt and the cost; \`--format json\` has every prompt) and every place it was published: one line per placement, so an ad in three ad sets has three lines. Each Meta line names the campaign, ad set and ad by their name now, with the name they had when published in brackets when it changed, and the ad's spend and purchases. Leads and sales are per item, credited to its SKU. Everything joins by Meta id, so a renamed campaign or an ad whose name lost its SKU still shows. For SQL, the same lines are in the \`content_trail\` and \`content_trail_results\` views.
|
|
129
|
+
|
|
130
|
+
## The Facebook ads agent
|
|
131
|
+
|
|
132
|
+
The daily Facebook ads agent follows its own playbook: install it with \`${cli} skill install meta-ads\` (or \`--codex\` for an AGENTS.md in the current directory) and read it with \`${cli} docs meta-ads\`. It works in runs (\`${cli} agent start\`, \`${cli} agent finish\`), keeps four memory notes (\`${cli} memory\`), a brand profile (\`${cli} brand\`), and makes proposals (\`${cli} proposals\`) that a person approves in the web app. Nothing it does is sent to Meta.
|
|
133
|
+
|
|
134
|
+
## The P3 refresh helper
|
|
135
|
+
|
|
136
|
+
The weekly P3 refresh helper is a second agent. Pass \`--agent p3_refresh\` to the agent and memory commands to use it instead of the default \`--agent meta_ads\`. \`${cli} agent draft --agent p3_refresh\` prints the counted draft its run starts from: the P3 purchase campaign's ad sets ranked over 7, 14 and 30 completed days, the audience template, and every P1 winner marked new, already in P3, or unresolved. Its run then proposes one \`p3_refresh_batch\` with \`${cli} proposals create\`: the ad set to retire, the template ad set, and the roster of ads, for a person to approve. Its playbook installs with \`${cli} skill install p3-refresh\` (or \`--codex\` for a Codex skill under \`$CODEX_HOME/skills\`) and prints with \`${cli} docs p3-refresh\`.
|
|
137
|
+
|
|
138
|
+
## Workers
|
|
139
|
+
|
|
140
|
+
A worker is a skill from the library that runs for this workspace on a schedule. \`${cli} worker list\` shows each one: its skill, the version in use (and one on trial), schedule and time zone, mode, runner, whether it is on, its next run, and how its last run went. \`${cli} worker runs <worker-id | skill> [--limit <n>]\` shows its last runs, newest first: status (a skipped run says why), when it was queued and finished, how long it took, its cost as AI, tools and compute, its error, and the first line of its report. \`${cli} worker run-now <worker-id | skill>\` queues one run now, even for a worker that is off. It is refused (\`open_run\`) while the worker has a run queued or going, and (\`monthly_cap\`) when the run could take the workspace over its monthly limit for workers. A worker run's own key cannot start runs. Only the operator changes a worker's schedule, mode or limits.
|
|
141
|
+
|
|
142
|
+
A worker's run acts as its skill's agent, the way the Facebook ads agent works in runs. Inside a run the agent and memory commands use \`--agent skill\` unless told otherwise, and only a worker run's own key can. A published skill should run \`${cli} agent start\` first, which opens the run's log and prints its run id and notes, and \`${cli} agent finish <run-id> --status succeeded|failed --file <report.md>\` last, with a report whose first line says what the run did. Each run of the worker gets a run of its own; the four notes (\`${cli} memory\`) carry over from one run to the next. A skill's agent proposes draft ads (\`creative\`) with \`${cli} proposals create\` and the run id, under the same daily limit as a new Facebook ads agent, and nothing else.
|
|
143
|
+
|
|
144
|
+
## Output
|
|
145
|
+
|
|
146
|
+
- \`--format table\` (the default) prints an aligned text table.
|
|
147
|
+
- \`--format json\` prints the raw API response. Use it when you need to parse the result.
|
|
148
|
+
- Errors go to stderr. A SQL error prints the Postgres error code, for example \`sql_42P01: relation "x" does not exist\`. A wrong table or column name adds a \`Hint:\` line with the nearest real names.
|
|
149
|
+
|
|
150
|
+
## Exit codes
|
|
151
|
+
|
|
152
|
+
- 0: success.
|
|
153
|
+
- 1: request or SQL error, or a tool run that failed.
|
|
154
|
+
- 2: usage error (unknown command or missing argument).
|
|
155
|
+
- 3: not logged in, or the API key was rejected.
|
|
156
|
+
|
|
157
|
+
## Commands
|
|
158
|
+
|
|
159
|
+
\`\`\`
|
|
160
|
+
${cli} login validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it
|
|
161
|
+
${cli} logout delete saved credentials
|
|
162
|
+
${cli} whoami show the current workspace
|
|
163
|
+
${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
|
|
164
|
+
${cli} warehouse schema [--summary] [--table <name> ...] [--search <words>] list tables and columns; --summary for one row per table
|
|
165
|
+
${cli} warehouse query "<sql>" run one read-only query (or --file <path>, or stdin)
|
|
166
|
+
${cli} funnel [--from <date>] [--to <date>] [--grain day|week|month] spend, leads, calls and sales per period, with totals
|
|
167
|
+
${cli} money [--from <date>] [--to <date>] [--grain day|week|month] money in, refunds, ad spend, payouts and what is left, per period, with totals
|
|
168
|
+
${cli} campaigns [--from <date>] [--to <date>] [--channel <channel>] [--limit <n>] each campaign's results, best first, with totals
|
|
169
|
+
${cli} sources list list data sources and their health
|
|
170
|
+
${cli} sources show <id> show one source's health, tables and last sync runs
|
|
171
|
+
${cli} sources settings <id> [<key>=<value> ...] show a key source's settings, or check them with its saved key and save them
|
|
172
|
+
${cli} intake list list webhook addresses and their health
|
|
173
|
+
${cli} intake create <label> [--kind generic|ghl_workflow|aloware|webinarfuel|stealth_seminar|mailgun] [--retention-days <n>] [--signing-key-stdin] make a webhook address; prints its link and token once
|
|
174
|
+
${cli} intake rotate <id> replace an address's token; prints the new link and token once
|
|
175
|
+
${cli} intake signing-key <id> --signing-key-stdin replace the workspace's signing key for that address's kind (mailgun)
|
|
176
|
+
${cli} intake disable <id> | enable <id> turn an address off or back on
|
|
177
|
+
${cli} intake rename <id> <label> | retention <id> <days>
|
|
178
|
+
${cli} intake delete <id> --yes delete an address and every event it saved
|
|
179
|
+
${cli} import csv <file> --table <name> load a CSV file as the table import_<name>, replacing an earlier import
|
|
180
|
+
${cli} import remove --table <name> --yes drop the table import_<name> and its import
|
|
181
|
+
${cli} tools list list generation and research tools
|
|
182
|
+
${cli} tools run <tool> --input '<json>' [--idempotency-key <key>] [--wait <seconds>]
|
|
183
|
+
${cli} tools get <run-id> show a tool run, waiting for it to finish (--wait)
|
|
184
|
+
${cli} creatives list list creatives (--status, --kind, --limit)
|
|
185
|
+
${cli} creatives show <sku> show one creative with its spec and lineage
|
|
186
|
+
${cli} creatives download <sku | asset-path | asset-link> [--out <path>] [--open] save a creative's image or video
|
|
187
|
+
${cli} creatives create --run <run-id> [--lineage '<json>' | @file.json] record a run's output with a new SKU
|
|
188
|
+
${cli} content create --kind <kind> [--body ...] [--file <path>] [--note <why>] [--plan <plan-id>] save made content with a new SKU
|
|
189
|
+
${cli} content search [--text <words>] [--mode word|meaning] [--kind <kind>] [--platform <p>] [--origin <o>] [--from <date>] [--to <date>] [--sort <column>] [--order asc|desc] [--limit <n>] find content with its numbers
|
|
190
|
+
${cli} content pull <dir> [--kind <kind>] [--platform <p>] [--origin <o>] [--media] [--full] copy the library to a folder of Markdown files; rerun for changes
|
|
191
|
+
${cli} content show <sku | item-id> | content edit <sku> [...] --note <why> | content status <sku> <status>
|
|
192
|
+
${cli} content diff <sku | item-id> [--from <n>] [--to <n>] what changed between two versions
|
|
193
|
+
${cli} content publish <sku> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <id>] [--version <n>] [--run <run-id>] publish an approved item
|
|
194
|
+
${cli} content reconcile <publication-id> check whether a publish left sending landed
|
|
195
|
+
${cli} content unpublish <deployment-id> [--force] take one deployed landing page down
|
|
196
|
+
${cli} content publish-record <sku> --file <publication.json> record a publish before sending it
|
|
197
|
+
${cli} content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--input '<json>']
|
|
198
|
+
${cli} content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>] tag a link with its SKU
|
|
199
|
+
${cli} agent start | show <run-id> | runs | finish <run-id> --status <s> --file <report> [--agent meta_ads|p3_refresh]
|
|
200
|
+
${cli} agent draft --agent p3_refresh the P3 refresh helper's counted draft
|
|
201
|
+
${cli} memory get [<note>] | memory set <note> --file <path> [--agent meta_ads|p3_refresh]
|
|
202
|
+
${cli} brand get | brand set --file <path> [--sources '<json>' | @file.json]
|
|
203
|
+
${cli} proposals create --input '<json>' | proposals list [--status <s>] [--run <run-id>]
|
|
204
|
+
${cli} content add-page <url> add a landing page and read it now
|
|
205
|
+
${cli} plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>'] [--landing <sku> | --landing-url <url>] [--run <agent-run-id>] plan a campaign push
|
|
206
|
+
${cli} plans show <plan-id> | plans add <plan-id> <sku> [--role <role>] [--position <n>] | plans remove <plan-id> <sku>
|
|
207
|
+
${cli} plans status <plan-id> draft|ready|publishing|live|done|cancelled
|
|
208
|
+
${cli} trail <plan-id | sku | item-id> how a plan's items were made, where they ran, and their results
|
|
209
|
+
${cli} worker list | worker runs <worker-id | skill> [--limit <n>] | worker run-now <worker-id | skill> this workspace's workers, their runs and costs, and Run now
|
|
210
|
+
${cli} skill install [meta-ads|p3-refresh] [--project] [--codex] install a Claude Code skill (or the playbook for Codex)
|
|
211
|
+
${cli} docs [meta-ads|p3-refresh] print this guide, or a helper's playbook
|
|
212
|
+
\`\`\`
|
|
213
|
+
|
|
214
|
+
Global options: \`--format table|json\`, \`--api-url <url>\` (or \`${envPrefix}_API_URL\`).
|
|
215
|
+
`;
|
|
216
|
+
export const skill = `---
|
|
217
|
+
name: ${cli}
|
|
218
|
+
description: Query a client's marketing data warehouse (ad spend, orders, synced sources) with read-only SQL, generate ad images and copy through paid tools, and read the creative ledger, through the \`${cli}\` CLI. Use when the user asks about marketing performance, ad spend, ROAS, orders, making ad creatives, or anything stored in their ${brand.name} warehouse.
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
${guide}`;
|