@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/main.js
ADDED
|
@@ -0,0 +1,2151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { mkdir, readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { api, ApiError, apiDownload, apiText, apiUpload, CliError, exitCodes, getSigned, IMPORT_LIMITS, IMPORT_NAME_PATTERN, putSigned, readCredentials, removeCredentials, resolveApiUrl, resolveConnection, UnreadableCredentialsError, writeCredentials, } from "./api.js";
|
|
8
|
+
import { brand, envPrefix } from "./brand.js";
|
|
9
|
+
import { firstSymlink, inRunFolder, writeFileAtomic } from "./files.js";
|
|
10
|
+
import { guide, skill } from "./guide.js";
|
|
11
|
+
import { PULL_STATE_FILE, pullContent } from "./pull.js";
|
|
12
|
+
import { AGENTS_MD_MARKER, META_ADS_SKILL_NAME, metaAdsAgentsMd, metaAdsPlaybook, metaAdsSkill, P3_REFRESH_SKILL_NAME, p3RefreshPlaybook, p3RefreshSkill, } from "./playbooks.js";
|
|
13
|
+
import { AGENT_ENV, HARNESS_COMMAND_ENV, RUNNER_TOKEN_ENV, runLoop, runOnce } from "./runner/run.js";
|
|
14
|
+
import { SKILL_NAME_PATTERN } from "./skill-zip.js";
|
|
15
|
+
import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
|
|
16
|
+
const DEFAULT_WAIT_SECONDS = 300;
|
|
17
|
+
const SERVER_WAIT_CAP = 50;
|
|
18
|
+
const commands = {
|
|
19
|
+
login: {
|
|
20
|
+
usage: `login (paste the key when asked, pipe it on stdin, or set ${envPrefix}_API_KEY)`,
|
|
21
|
+
async run(args, options) {
|
|
22
|
+
if (args.length > 0) {
|
|
23
|
+
throw new CliError(`login takes no arguments. Pipe the key on stdin or set ${envPrefix}_API_KEY.`, exitCodes.usage);
|
|
24
|
+
}
|
|
25
|
+
const apiKey = await readKeyFromUser();
|
|
26
|
+
const stored = await readCredentials().catch((error) => {
|
|
27
|
+
if (error instanceof UnreadableCredentialsError)
|
|
28
|
+
return null;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
const apiUrl = resolveApiUrl(options.apiUrl, stored);
|
|
32
|
+
const me = await api({ apiUrl, apiKey }, "/api/v1/me");
|
|
33
|
+
const path = await writeCredentials({ apiKey, apiUrl });
|
|
34
|
+
if (options.format === "json")
|
|
35
|
+
return printJson(me);
|
|
36
|
+
console.log(`Logged in to ${me.workspace.name} (${me.workspace.slug}) at ${apiUrl}.`);
|
|
37
|
+
console.log(`Saved credentials to ${path}.`);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
logout: {
|
|
41
|
+
usage: "logout",
|
|
42
|
+
async run() {
|
|
43
|
+
await removeCredentials();
|
|
44
|
+
console.log("Logged out.");
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
whoami: {
|
|
48
|
+
usage: "whoami",
|
|
49
|
+
async run(_args, options) {
|
|
50
|
+
const me = await api(await resolveConnection(options.apiUrl), "/api/v1/me");
|
|
51
|
+
if (options.format === "json")
|
|
52
|
+
return printJson(me);
|
|
53
|
+
const { name, slug, id } = me.workspace;
|
|
54
|
+
console.log(renderTable(["workspace", "slug", "id"], [{ workspace: name, slug, id }]));
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"warehouse schema": {
|
|
58
|
+
usage: "warehouse schema [--summary] [--table <name> ...] [--search <words>]",
|
|
59
|
+
async run(args, options) {
|
|
60
|
+
if (args.length !== 0)
|
|
61
|
+
throw new CliError("Usage: warehouse schema [--summary] [--table <name> ...] [--search <words>]", exitCodes.usage);
|
|
62
|
+
const query = new URLSearchParams();
|
|
63
|
+
if (options.summary)
|
|
64
|
+
query.set("summary", "1");
|
|
65
|
+
if (options.tables.length > 0)
|
|
66
|
+
query.set("tables", options.tables.join(","));
|
|
67
|
+
if (options.search !== undefined)
|
|
68
|
+
query.set("search", options.search);
|
|
69
|
+
const path = `/api/v1/warehouse/schema${query.size > 0 ? `?${query}` : ""}`;
|
|
70
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
71
|
+
if (options.summary) {
|
|
72
|
+
const summary = await api(connection, path);
|
|
73
|
+
if (options.format === "json")
|
|
74
|
+
return printJson(summary);
|
|
75
|
+
if (summary.tables.length === 0)
|
|
76
|
+
return console.log("No tables yet.");
|
|
77
|
+
return console.log(renderTable(["table", "kind", "source", "columns", "summary"], summary.tables.map((t) => ({ table: t.name, kind: t.kind, source: t.source ?? "", columns: t.columns, summary: t.summary ?? "" }))));
|
|
78
|
+
}
|
|
79
|
+
const schema = await api(connection, path);
|
|
80
|
+
if (options.format === "json")
|
|
81
|
+
return printJson(schema);
|
|
82
|
+
if (schema.missing?.length)
|
|
83
|
+
console.error(`No such table: ${schema.missing.join(", ")}`);
|
|
84
|
+
if (schema.tables.length === 0) {
|
|
85
|
+
if (options.tables.length > 0)
|
|
86
|
+
return;
|
|
87
|
+
return console.log(options.search === undefined ? "No tables yet." : "No tables match.");
|
|
88
|
+
}
|
|
89
|
+
const described = schema.tables.filter((t) => t.description !== null);
|
|
90
|
+
if (described.length > 0) {
|
|
91
|
+
console.log(renderTable(["table", "description"], described.map((t) => ({ table: t.name, description: t.description }))));
|
|
92
|
+
console.log("");
|
|
93
|
+
}
|
|
94
|
+
const rows = schema.tables.flatMap((table) => table.columns.map((c) => ({
|
|
95
|
+
table: table.name,
|
|
96
|
+
column: c.name,
|
|
97
|
+
type: c.type,
|
|
98
|
+
nullable: c.nullable ? "yes" : "no",
|
|
99
|
+
description: c.description ?? "",
|
|
100
|
+
})));
|
|
101
|
+
console.log(renderTable(["table", "column", "type", "nullable", "description"], rows));
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
"warehouse query": {
|
|
105
|
+
usage: 'warehouse query "<sql>" | --file <path> | < query.sql',
|
|
106
|
+
async run(args, options) {
|
|
107
|
+
const sql = await readSql(args, options.file);
|
|
108
|
+
const text = await apiText(await resolveConnection(options.apiUrl), "/api/v1/warehouse/query", { sql });
|
|
109
|
+
if (options.format === "json")
|
|
110
|
+
return console.log(text);
|
|
111
|
+
const result = JSON.parse(text);
|
|
112
|
+
const exact = JSON.parse(text, keepNumberDigits);
|
|
113
|
+
const rows = result.rows.map((row, i) => Object.fromEntries(Object.entries(row).map(([k, v]) => [k, typeof v === "number" ? exact.rows[i][k] : v])));
|
|
114
|
+
console.log(renderTable(result.columns.map((c) => c.name), rows));
|
|
115
|
+
console.log(`(${result.rows.length} ${result.rows.length === 1 ? "row" : "rows"})`);
|
|
116
|
+
if (result.truncated) {
|
|
117
|
+
console.error(`Results were cut off (${result.truncated}). Aggregate in SQL or add LIMIT.`);
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
funnel: {
|
|
122
|
+
usage: "funnel [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--grain day|week|month]",
|
|
123
|
+
async run(args, options) {
|
|
124
|
+
if (args.length > 0)
|
|
125
|
+
throw new CliError("Usage: funnel [--from <date>] [--to <date>] [--grain day|week|month]", exitCodes.usage);
|
|
126
|
+
const query = reportQuery(options, { grain: options.grain });
|
|
127
|
+
const { funnel } = await api(await resolveConnection(options.apiUrl), `/api/v1/reports/funnel${query}`);
|
|
128
|
+
if (options.format === "json")
|
|
129
|
+
return printJson({ funnel });
|
|
130
|
+
printReport(`Funnel by ${funnel.grain}`, funnel, ["period", "currency"], FUNNEL_NUMBERS);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
money: {
|
|
134
|
+
usage: "money [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--grain day|week|month]",
|
|
135
|
+
async run(args, options) {
|
|
136
|
+
if (args.length > 0)
|
|
137
|
+
throw new CliError("Usage: money [--from <date>] [--to <date>] [--grain day|week|month]", exitCodes.usage);
|
|
138
|
+
const query = reportQuery(options, { grain: options.grain });
|
|
139
|
+
const { money } = await api(await resolveConnection(options.apiUrl), `/api/v1/reports/money${query}`);
|
|
140
|
+
if (options.format === "json")
|
|
141
|
+
return printJson({ money });
|
|
142
|
+
printReport(`Money by ${money.grain}`, money, ["period", "currency"], MONEY_NUMBERS);
|
|
143
|
+
if (money.by_source.length > 0)
|
|
144
|
+
console.log(`\nMoney in by payment source:\n${renderTable(["source", "currency", "gross", "refunds", "net", "orders"], money.by_source)}`);
|
|
145
|
+
if (money.refund_days.length > 0)
|
|
146
|
+
console.log(`\nBiggest refund days:\n${renderTable(["date", "currency", "refunds"], money.refund_days)}`);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
campaigns: {
|
|
150
|
+
usage: "campaigns [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--channel <channel>] [--limit <n>]",
|
|
151
|
+
async run(args, options) {
|
|
152
|
+
if (args.length > 0)
|
|
153
|
+
throw new CliError("Usage: campaigns [--from <date>] [--to <date>] [--channel <channel>] [--limit <n>]", exitCodes.usage);
|
|
154
|
+
const query = reportQuery(options, { channel: options.channel, limit: options.limit });
|
|
155
|
+
const { campaignResults } = await api(await resolveConnection(options.apiUrl), `/api/v1/reports/campaigns${query}`);
|
|
156
|
+
if (options.format === "json")
|
|
157
|
+
return printJson({ campaignResults });
|
|
158
|
+
const title = campaignResults.channel === null ? "Campaigns, best first" : `${campaignResults.channel} campaigns, best first`;
|
|
159
|
+
printReport(title, campaignResults, ["channel", "campaign_id", "campaign_name", "currency"], CAMPAIGN_NUMBERS);
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
"tools list": {
|
|
163
|
+
usage: "tools list",
|
|
164
|
+
async run(_args, options) {
|
|
165
|
+
const { tools } = await api(await resolveConnection(options.apiUrl), "/api/v1/tools");
|
|
166
|
+
if (options.format === "json")
|
|
167
|
+
return printJson({ tools });
|
|
168
|
+
console.log(renderTable(["id", "mode", "output", "title"], tools));
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
"tools run": {
|
|
172
|
+
usage: "tools run <tool> [--input '<json>' | --file <path> | < input.json] [--idempotency-key <key>] [--wait <seconds>]",
|
|
173
|
+
async run(args, options) {
|
|
174
|
+
const [tool, ...rest] = args;
|
|
175
|
+
if (!tool || rest.length > 0)
|
|
176
|
+
throw new CliError("Usage: tools run <tool> [--input '<json>']", exitCodes.usage);
|
|
177
|
+
const input = await readToolInput(options);
|
|
178
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
179
|
+
const body = {
|
|
180
|
+
input,
|
|
181
|
+
...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
182
|
+
waitSeconds: Math.min(options.wait, SERVER_WAIT_CAP),
|
|
183
|
+
};
|
|
184
|
+
const { run } = await api(connection, `/api/v1/tools/${encodeURIComponent(tool)}/runs`, body);
|
|
185
|
+
printRun(await waitForRun(connection, run, options.wait), options.format);
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
"tools get": {
|
|
189
|
+
usage: "tools get <run-id> [--wait <seconds>]",
|
|
190
|
+
async run(args, options) {
|
|
191
|
+
if (args.length !== 1)
|
|
192
|
+
throw new CliError("Usage: tools get <run-id>", exitCodes.usage);
|
|
193
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
194
|
+
const { run } = await api(connection, `/api/v1/runs/${encodeURIComponent(args[0])}`);
|
|
195
|
+
printRun(await waitForRun(connection, run, options.wait), options.format);
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
"sources list": {
|
|
199
|
+
usage: "sources list",
|
|
200
|
+
async run(args, options) {
|
|
201
|
+
if (args.length > 0)
|
|
202
|
+
throw new CliError("Usage: sources list", exitCodes.usage);
|
|
203
|
+
const { sources } = await api(await resolveConnection(options.apiUrl), "/api/v1/sources");
|
|
204
|
+
if (options.format === "json")
|
|
205
|
+
return printJson({ sources });
|
|
206
|
+
if (sources.length === 0)
|
|
207
|
+
return console.log("No sources yet.");
|
|
208
|
+
const rows = sources.map((s) => ({
|
|
209
|
+
id: s.id,
|
|
210
|
+
name: sourceLabel(s),
|
|
211
|
+
key: s.name,
|
|
212
|
+
connector: s.connectorName ?? s.connector,
|
|
213
|
+
status: s.status,
|
|
214
|
+
"last synced": s.lastSyncedAt,
|
|
215
|
+
"next due": s.nextDueAt,
|
|
216
|
+
"last error": s.lastError && excerpt(s.lastError),
|
|
217
|
+
"missing access": (s.missingAccess ?? []).map((m) => m.part).join(", "),
|
|
218
|
+
retrying: retryingText(s),
|
|
219
|
+
}));
|
|
220
|
+
console.log(renderTable(["id", "name", "key", "connector", "status", "last synced", "next due", "last error", "missing access", "retrying"], rows));
|
|
221
|
+
for (const s of sources) {
|
|
222
|
+
for (const m of s.missingAccess ?? [])
|
|
223
|
+
console.log(`${sourceLabel(s)}: ${m.message}`);
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
"sources show": {
|
|
228
|
+
usage: "sources show <id>",
|
|
229
|
+
async run(args, options) {
|
|
230
|
+
if (args.length !== 1)
|
|
231
|
+
throw new CliError("Usage: sources show <id>", exitCodes.usage);
|
|
232
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
233
|
+
const path = `/api/v1/sources/${encodeURIComponent(args[0])}`;
|
|
234
|
+
const { source } = await api(connection, path);
|
|
235
|
+
const { runs } = await api(connection, `${path}/runs?limit=${SOURCE_RUNS_SHOWN}`);
|
|
236
|
+
if (options.format === "json")
|
|
237
|
+
return printJson({ source, runs });
|
|
238
|
+
const fields = [
|
|
239
|
+
{ field: "id", value: source.id },
|
|
240
|
+
{ field: "name", value: sourceLabel(source) },
|
|
241
|
+
{ field: "key", value: source.name },
|
|
242
|
+
{ field: "connector", value: source.connectorName ? `${source.connectorName} (${source.connector})` : source.connector },
|
|
243
|
+
{ field: "connection", value: source.connectionId ?? "none" },
|
|
244
|
+
{ field: "status", value: source.status },
|
|
245
|
+
{ field: "last attempted", value: source.lastAttemptedAt ?? "never" },
|
|
246
|
+
{ field: "last synced", value: source.lastSyncedAt ?? "never" },
|
|
247
|
+
{ field: "next due", value: source.nextDueAt ?? "not scheduled" },
|
|
248
|
+
{ field: "last error", value: source.lastError ?? "none" },
|
|
249
|
+
{ field: "retrying since", value: source.retryingSince ?? "not retrying" },
|
|
250
|
+
{ field: "retrying", value: retryingText(source) || "nothing" },
|
|
251
|
+
{ field: "history", value: historyText(source.history) },
|
|
252
|
+
{ field: "created", value: source.createdAt },
|
|
253
|
+
];
|
|
254
|
+
console.log(renderTable(["field", "value"], fields));
|
|
255
|
+
const failing = source.failingParts ?? [];
|
|
256
|
+
if (failing.length > 0)
|
|
257
|
+
console.log(`\nRetrying:\n${failing.map((p) => `- ${p.part} since ${p.since}: ${excerpt(p.error)}`).join("\n")}`);
|
|
258
|
+
const missing = source.missingAccess ?? [];
|
|
259
|
+
if (missing.length > 0)
|
|
260
|
+
console.log(`\nMissing access:\n${missing.map((m) => `- ${m.message}`).join("\n")}`);
|
|
261
|
+
if (source.tables.length > 0)
|
|
262
|
+
console.log(`\n${renderTable(["name", "rows", "earliest", "latest"], source.tables)}`);
|
|
263
|
+
if (runs.length === 0)
|
|
264
|
+
return console.log("\nNo sync runs yet.");
|
|
265
|
+
const runRows = runs.map((r) => ({ ...r, error: r.error && excerpt(r.error) }));
|
|
266
|
+
console.log(`\n${renderTable(["startedAt", "finishedAt", "status", "rowsWritten", "apiCalls", "error"], runRows)}`);
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
"sources settings": {
|
|
270
|
+
usage: "sources settings <id> [<key>=<value> ...]",
|
|
271
|
+
async run(args, options) {
|
|
272
|
+
const [id, ...assignments] = args;
|
|
273
|
+
if (!id)
|
|
274
|
+
throw new CliError("Usage: sources settings <id> [<key>=<value> ...]", exitCodes.usage);
|
|
275
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
276
|
+
const path = `/api/v1/sources/${encodeURIComponent(id)}/settings`;
|
|
277
|
+
let { settings } = await api(connection, path);
|
|
278
|
+
if (assignments.length > 0) {
|
|
279
|
+
const keys = settings.fields.map((f) => f.key);
|
|
280
|
+
const values = {};
|
|
281
|
+
for (const assignment of assignments) {
|
|
282
|
+
const at = assignment.indexOf("=");
|
|
283
|
+
const key = at < 0 ? assignment : assignment.slice(0, at);
|
|
284
|
+
if (at < 0 || !keys.includes(key)) {
|
|
285
|
+
throw new CliError(`Usage: sources settings <id> <key>=<value> ...; this source's settings are ${keys.join(", ")}`, exitCodes.usage);
|
|
286
|
+
}
|
|
287
|
+
values[key] = assignment.slice(at + 1);
|
|
288
|
+
}
|
|
289
|
+
({ settings } = await api(connection, path, { values }, "PUT"));
|
|
290
|
+
}
|
|
291
|
+
if (options.format === "json")
|
|
292
|
+
return printJson({ settings });
|
|
293
|
+
console.log(renderTable(["key", "label", "value"], settings.fields.map((f) => ({ key: f.key, label: f.label, value: settings.values[f.key] ?? "" }))));
|
|
294
|
+
},
|
|
295
|
+
},
|
|
296
|
+
"intake list": {
|
|
297
|
+
usage: "intake list",
|
|
298
|
+
async run(args, options) {
|
|
299
|
+
if (args.length > 0)
|
|
300
|
+
throw new CliError("Usage: intake list", exitCodes.usage);
|
|
301
|
+
const { endpoints } = await api(await resolveConnection(options.apiUrl), INTAKE_PATH);
|
|
302
|
+
if (options.format === "json")
|
|
303
|
+
return printJson({ endpoints });
|
|
304
|
+
if (endpoints.length === 0)
|
|
305
|
+
return console.log("No webhook addresses yet.");
|
|
306
|
+
const rows = endpoints.map((e) => ({
|
|
307
|
+
id: e.id,
|
|
308
|
+
label: e.label,
|
|
309
|
+
state: e.disabled ? "off" : "on",
|
|
310
|
+
"kept (days)": e.retentionDays,
|
|
311
|
+
"events 24h": e.health.eventsLast24h,
|
|
312
|
+
"last event": e.health.lastEventAt ?? "never",
|
|
313
|
+
"rows held": e.health.rowsHeld,
|
|
314
|
+
}));
|
|
315
|
+
console.log(renderTable(["id", "label", "state", "kept (days)", "events 24h", "last event", "rows held"], rows));
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
"intake create": {
|
|
319
|
+
usage: "intake create <label> [--kind <kind>] [--retention-days <n>] [--signing-key-stdin]",
|
|
320
|
+
async run(args, options) {
|
|
321
|
+
if (args.length !== 1)
|
|
322
|
+
throw new CliError("Usage: intake create <label> [--kind <kind>] [--retention-days <n>] [--signing-key-stdin]", exitCodes.usage);
|
|
323
|
+
const retentionDays = options.retentionDays === undefined ? undefined : parseRetentionDays(options.retentionDays);
|
|
324
|
+
const signingKey = options.signingKeyStdin ? await readSigningKey() : undefined;
|
|
325
|
+
const body = {
|
|
326
|
+
label: args[0],
|
|
327
|
+
...(options.kind === undefined ? {} : { kind: options.kind }),
|
|
328
|
+
...(retentionDays === undefined ? {} : { retentionDays }),
|
|
329
|
+
...(signingKey === undefined ? {} : { signingKey }),
|
|
330
|
+
};
|
|
331
|
+
printIntakeToken(await api(await resolveConnection(options.apiUrl), INTAKE_PATH, body), options.format);
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
"intake rotate": {
|
|
335
|
+
usage: "intake rotate <id>",
|
|
336
|
+
async run(args, options) {
|
|
337
|
+
if (args.length !== 1)
|
|
338
|
+
throw new CliError("Usage: intake rotate <id>", exitCodes.usage);
|
|
339
|
+
const path = `${intakePath(args[0])}/rotate`;
|
|
340
|
+
printIntakeToken(await api(await resolveConnection(options.apiUrl), path, {}), options.format);
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
"intake signing-key": {
|
|
344
|
+
usage: "intake signing-key <id> --signing-key-stdin",
|
|
345
|
+
async run(args, options) {
|
|
346
|
+
if (args.length !== 1 || !options.signingKeyStdin)
|
|
347
|
+
throw new CliError("Usage: intake signing-key <id> --signing-key-stdin", exitCodes.usage);
|
|
348
|
+
const signingKey = await readSigningKey();
|
|
349
|
+
const path = `${intakePath(args[0])}/signing-key`;
|
|
350
|
+
const { endpoint } = await api(await resolveConnection(options.apiUrl), path, { signingKey }, "PUT");
|
|
351
|
+
if (options.format === "json")
|
|
352
|
+
return printJson({ endpoint });
|
|
353
|
+
console.log(`Replaced the ${endpoint.kind} signing key. Every ${endpoint.kind} address in the workspace now checks against it.`);
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
"intake disable": {
|
|
357
|
+
usage: "intake disable <id>",
|
|
358
|
+
async run(args, options) {
|
|
359
|
+
if (args.length !== 1)
|
|
360
|
+
throw new CliError("Usage: intake disable <id>", exitCodes.usage);
|
|
361
|
+
await patchIntake(args[0], { disabled: true }, options, (e) => `Turned off ${e.label}. Senders now get 404 until you turn it on.`);
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
"intake enable": {
|
|
365
|
+
usage: "intake enable <id>",
|
|
366
|
+
async run(args, options) {
|
|
367
|
+
if (args.length !== 1)
|
|
368
|
+
throw new CliError("Usage: intake enable <id>", exitCodes.usage);
|
|
369
|
+
await patchIntake(args[0], { disabled: false }, options, (e) => `Turned on ${e.label}.`);
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
"intake rename": {
|
|
373
|
+
usage: "intake rename <id> <label>",
|
|
374
|
+
async run(args, options) {
|
|
375
|
+
if (args.length !== 2)
|
|
376
|
+
throw new CliError("Usage: intake rename <id> <label>", exitCodes.usage);
|
|
377
|
+
await patchIntake(args[0], { label: args[1] }, options, (e) => `Renamed to ${e.label}.`);
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
"intake retention": {
|
|
381
|
+
usage: "intake retention <id> <days>",
|
|
382
|
+
async run(args, options) {
|
|
383
|
+
if (args.length !== 2)
|
|
384
|
+
throw new CliError("Usage: intake retention <id> <days>", exitCodes.usage);
|
|
385
|
+
const retentionDays = parseRetentionDays(args[1]);
|
|
386
|
+
await patchIntake(args[0], { retentionDays }, options, (e) => `${e.label} now keeps events for ${e.retentionDays} days.`);
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
"intake delete": {
|
|
390
|
+
usage: "intake delete <id> --yes",
|
|
391
|
+
async run(args, options) {
|
|
392
|
+
if (args.length !== 1)
|
|
393
|
+
throw new CliError("Usage: intake delete <id> --yes", exitCodes.usage);
|
|
394
|
+
if (!options.yes) {
|
|
395
|
+
throw new CliError("This deletes the address and every event it has saved. Add --yes to confirm: intake delete <id> --yes", exitCodes.usage);
|
|
396
|
+
}
|
|
397
|
+
const result = await api(await resolveConnection(options.apiUrl), intakePath(args[0]), undefined, "DELETE");
|
|
398
|
+
if (options.format === "json")
|
|
399
|
+
return printJson(result);
|
|
400
|
+
console.log("Deleted the address and its saved events.");
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
"import csv": {
|
|
404
|
+
usage: "import csv <file> --table <name>",
|
|
405
|
+
async run(args, options) {
|
|
406
|
+
const usage = "Usage: import csv <file> --table <name>";
|
|
407
|
+
if (args.length !== 1 || !options.table)
|
|
408
|
+
throw new CliError(usage, exitCodes.usage);
|
|
409
|
+
const name = importName(options.table);
|
|
410
|
+
const file = await readImportFile(args[0]);
|
|
411
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
412
|
+
const result = await uploadCsv(connection, name, file);
|
|
413
|
+
if (options.format === "json")
|
|
414
|
+
return printJson(result);
|
|
415
|
+
console.log(`Imported ${result.rows} ${result.rows === 1 ? "row" : "rows"} into ${result.table}.`);
|
|
416
|
+
console.log(renderTable(["column", "type", "header"], result.columns.map((c) => ({ column: c.name, type: c.type, header: c.sourceHeader }))));
|
|
417
|
+
},
|
|
418
|
+
},
|
|
419
|
+
"import remove": {
|
|
420
|
+
usage: "import remove --table <name> --yes",
|
|
421
|
+
async run(args, options) {
|
|
422
|
+
const usage = "Usage: import remove --table <name> --yes";
|
|
423
|
+
if (args.length !== 0 || !options.table)
|
|
424
|
+
throw new CliError(usage, exitCodes.usage);
|
|
425
|
+
const name = importName(options.table);
|
|
426
|
+
if (!options.yes) {
|
|
427
|
+
throw new CliError(`This drops the table import_${name} and all its rows. Add --yes to confirm: import remove --table ${name} --yes`, exitCodes.usage);
|
|
428
|
+
}
|
|
429
|
+
const path = `/api/v1/imports/csv?table=${encodeURIComponent(name)}`;
|
|
430
|
+
const result = await api(await resolveConnection(options.apiUrl), path, undefined, "DELETE");
|
|
431
|
+
if (options.format === "json")
|
|
432
|
+
return printJson(result);
|
|
433
|
+
console.log(`Removed ${result.table} and its rows.`);
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
"creatives list": {
|
|
437
|
+
usage: "creatives list [--status <status>] [--kind static|video|copy] [--limit <n>]",
|
|
438
|
+
async run(_args, options) {
|
|
439
|
+
const query = new URLSearchParams();
|
|
440
|
+
if (options.status)
|
|
441
|
+
query.set("status", options.status);
|
|
442
|
+
if (options.kind)
|
|
443
|
+
query.set("kind", options.kind);
|
|
444
|
+
if (options.limit)
|
|
445
|
+
query.set("limit", options.limit);
|
|
446
|
+
const path = `/api/v1/creatives${query.size ? `?${query}` : ""}`;
|
|
447
|
+
const { creatives } = await api(await resolveConnection(options.apiUrl), path);
|
|
448
|
+
if (options.format === "json")
|
|
449
|
+
return printJson({ creatives });
|
|
450
|
+
if (creatives.length === 0)
|
|
451
|
+
return console.log("No creatives yet.");
|
|
452
|
+
const rows = creatives.map((c) => ({ ...c, tool: c.spec.tool, output: c.assetUrl ?? excerpt(c.copyText ?? "") }));
|
|
453
|
+
console.log(renderTable(["sku", "kind", "status", "tool", "createdAt", "output"], rows));
|
|
454
|
+
},
|
|
455
|
+
},
|
|
456
|
+
"creatives show": {
|
|
457
|
+
usage: "creatives show <sku>",
|
|
458
|
+
async run(args, options) {
|
|
459
|
+
if (args.length !== 1)
|
|
460
|
+
throw new CliError("Usage: creatives show <sku>", exitCodes.usage);
|
|
461
|
+
const { creative } = await api(await resolveConnection(options.apiUrl), `/api/v1/creatives/${encodeURIComponent(args[0])}`);
|
|
462
|
+
if (options.format === "json")
|
|
463
|
+
return printJson({ creative });
|
|
464
|
+
const fields = {
|
|
465
|
+
...creative,
|
|
466
|
+
tool: creative.spec.tool,
|
|
467
|
+
prompt: creative.spec.input?.prompt,
|
|
468
|
+
};
|
|
469
|
+
const shown = ["sku", "kind", "status", "tool", "prompt", "lineage", "assetUrl", "copyText", "metaAdId", "toolRunId", "createdAt"];
|
|
470
|
+
console.log(renderTable(["field", "value"], shown.filter((f) => fields[f] != null).map((f) => ({ field: f, value: fields[f] }))));
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
"creatives download": {
|
|
474
|
+
usage: "creatives download <sku | asset-path | asset-link> [--out <path>] [--open]",
|
|
475
|
+
async run(args, options) {
|
|
476
|
+
if (args.length !== 1)
|
|
477
|
+
throw new CliError("Usage: creatives download <sku | asset-path | asset-link> [--out <path>] [--open]", exitCodes.usage);
|
|
478
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
479
|
+
const target = await downloadTarget(args[0], connection);
|
|
480
|
+
const { bytes, contentType } = await apiDownload(connection, `${ASSET_ROUTE}${target.assetPath.split("/").map(encodeURIComponent).join("/")}`);
|
|
481
|
+
const path = await savePath(options.out, extname(target.fileName) ? target.fileName : `${target.fileName}.${extensionFor(contentType)}`);
|
|
482
|
+
await writeFileAtomic(path, bytes, 0o644).catch((error) => {
|
|
483
|
+
if (error.code === "ENOENT")
|
|
484
|
+
throw new CliError(`No folder at ${dirname(path)} to save into.`, exitCodes.usage);
|
|
485
|
+
throw error;
|
|
486
|
+
});
|
|
487
|
+
if (options.open)
|
|
488
|
+
openFile(resolve(path));
|
|
489
|
+
if (options.format === "json")
|
|
490
|
+
return printJson({ path: resolve(path), bytes: bytes.byteLength, contentType });
|
|
491
|
+
console.log(`Saved ${target.label} to ${path} (${fileSize(bytes.byteLength)}).`);
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
"creatives create": {
|
|
495
|
+
usage: "creatives create --run <tool-run-id> [--lineage '<json>' | @file.json]",
|
|
496
|
+
async run(args, options) {
|
|
497
|
+
if (args.length > 0 || !options.run)
|
|
498
|
+
throw new CliError("Usage: creatives create --run <tool-run-id> [--lineage '<json>' | @file.json]", exitCodes.usage);
|
|
499
|
+
const lineage = options.lineage === undefined ? undefined : await parseJson(options.lineage, "--lineage");
|
|
500
|
+
const { creatives } = await api(await resolveConnection(options.apiUrl), "/api/v1/creatives", {
|
|
501
|
+
runId: options.run,
|
|
502
|
+
...(lineage === undefined ? {} : { lineage }),
|
|
503
|
+
});
|
|
504
|
+
if (options.format === "json")
|
|
505
|
+
return printJson({ creatives });
|
|
506
|
+
console.log(renderTable(["sku", "kind", "status", "assetUrl"], creatives));
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
"content create": {
|
|
510
|
+
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>]]",
|
|
511
|
+
async run(args, options) {
|
|
512
|
+
if (args.length > 0 || !options.kind)
|
|
513
|
+
throw new CliError("Usage: content create --kind ad|email|sms|landing_page|video|social_post [...]", exitCodes.usage);
|
|
514
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
515
|
+
const content = await api(connection, "/api/v1/content", {
|
|
516
|
+
kind: options.kind,
|
|
517
|
+
...(await contentInput(options, connection)),
|
|
518
|
+
...(options.plan === undefined ? {} : { launchPlanId: options.plan }),
|
|
519
|
+
...(options.role === undefined ? {} : { launchPlanRole: options.role }),
|
|
520
|
+
});
|
|
521
|
+
printContent(content, options.format);
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
"content capture": {
|
|
525
|
+
usage: "content capture --kind <kind> --name <name> [--subject <s>] [--headline <s>] [--body <text> | @file] [--html @file] [--file <path> ...] [--transcript <text> | @file [--language <code>]] [--source <text>] [--url <url>] [--run <agent-run-id>]",
|
|
526
|
+
async run(args, options) {
|
|
527
|
+
if (args.length > 0 || !options.kind || !options.name) {
|
|
528
|
+
throw new CliError("Usage: content capture --kind ad|email|sms|landing_page|video|social_post --name <name> [...]", exitCodes.usage);
|
|
529
|
+
}
|
|
530
|
+
if (options.language !== undefined && options.transcript === undefined)
|
|
531
|
+
throw new CliError("--language goes with --transcript.", exitCodes.usage);
|
|
532
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
533
|
+
const content = await api(connection, "/api/v1/content/captures", {
|
|
534
|
+
kind: options.kind,
|
|
535
|
+
...(await contentInput(options, connection)),
|
|
536
|
+
transcript: options.transcript === undefined ? undefined : { text: await textOrFile(options.transcript), language: options.language },
|
|
537
|
+
source: options.source,
|
|
538
|
+
url: options.url,
|
|
539
|
+
});
|
|
540
|
+
printContent(content, options.format);
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
guide: {
|
|
544
|
+
usage: "guide (what this workspace's data holds, how fresh it is, and what is missing: read it first)",
|
|
545
|
+
async run(args, options) {
|
|
546
|
+
if (args.length > 0)
|
|
547
|
+
throw new CliError("Usage: guide", exitCodes.usage);
|
|
548
|
+
const guide = await api(await resolveConnection(options.apiUrl), "/api/v1/guide");
|
|
549
|
+
if (options.format === "json")
|
|
550
|
+
return printJson(guide);
|
|
551
|
+
process.stdout.write(guide.text);
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
"content search": {
|
|
555
|
+
usage: "content search [--text <words>] [--mode word|meaning] [--kind <kind>] [--platform <platform>] [--origin discovered|made] [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--sort recent|<scorecard column>] [--order desc|asc] [--limit <n>]",
|
|
556
|
+
async run(args, options) {
|
|
557
|
+
if (args.length > 0)
|
|
558
|
+
throw new CliError("Usage: content search [--text <words>] [--kind <kind>] [--sort <column>] [...]; put several words in quotes", exitCodes.usage);
|
|
559
|
+
const filters = { text: options.text, mode: options.mode, kind: options.kind, platform: options.platform, origin: options.origin, from: options.from, to: options.to, sort: options.sort, order: options.order, limit: options.limit };
|
|
560
|
+
const query = new URLSearchParams(Object.entries(filters).filter((entry) => entry[1] !== undefined));
|
|
561
|
+
const { results } = await api(await resolveConnection(options.apiUrl), `/api/v1/content${query.size ? `?${query}` : ""}`);
|
|
562
|
+
if (options.format === "json")
|
|
563
|
+
return printJson({ results });
|
|
564
|
+
if (results.length === 0)
|
|
565
|
+
return console.log("No content found.");
|
|
566
|
+
const sort = options.sort && results.some((r) => scoreCell(r.scorecards, options.sort) !== null) ? options.sort : null;
|
|
567
|
+
const meaning = results.some((r) => r.distance !== undefined);
|
|
568
|
+
const rows = results.map((r) => ({
|
|
569
|
+
ref: r.sku ?? r.id,
|
|
570
|
+
kind: r.kind,
|
|
571
|
+
platform: r.platform,
|
|
572
|
+
live: r.liveFrom === r.liveTo ? r.liveFrom : `${r.liveFrom} to ${r.liveTo}`,
|
|
573
|
+
...(sort ? { [sort]: scoreCell(r.scorecards, sort) } : {}),
|
|
574
|
+
...(meaning ? { distance: r.distance ?? null } : {}),
|
|
575
|
+
words: excerpt([r.version.subject ?? r.version.headline, r.version.snippet].filter(Boolean).join(": ")),
|
|
576
|
+
files: r.media.length || null,
|
|
577
|
+
transcript: r.hasTranscript ? "yes" : null,
|
|
578
|
+
}));
|
|
579
|
+
console.log(renderTable(["ref", "kind", "platform", "live", ...(sort ? [sort] : []), ...(meaning ? ["distance"] : []), "words", "files", "transcript"], rows));
|
|
580
|
+
},
|
|
581
|
+
},
|
|
582
|
+
"content show": {
|
|
583
|
+
usage: "content show <sku | item-id>",
|
|
584
|
+
async run(args, options) {
|
|
585
|
+
if (args.length !== 1)
|
|
586
|
+
throw new CliError("Usage: content show <sku | item-id>", exitCodes.usage);
|
|
587
|
+
const content = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}`);
|
|
588
|
+
printContent(content, options.format);
|
|
589
|
+
},
|
|
590
|
+
},
|
|
591
|
+
"content pull": {
|
|
592
|
+
usage: "content pull <dir> [--kind <kind>] [--platform <platform>] [--origin discovered|made] [--media] [--full]",
|
|
593
|
+
async run(args, options) {
|
|
594
|
+
if (args.length !== 1)
|
|
595
|
+
throw new CliError("Usage: content pull <dir> [--kind <kind>] [--platform <platform>] [--origin discovered|made] [--media] [--full]", exitCodes.usage);
|
|
596
|
+
const filters = Object.fromEntries(Object.entries({ kind: options.kind, platform: options.platform, origin: options.origin }).filter((e) => e[1] !== undefined));
|
|
597
|
+
const report = await pullContent(await resolveConnection(options.apiUrl), args[0], filters, { media: options.media, full: options.full });
|
|
598
|
+
if (options.format === "json")
|
|
599
|
+
return printJson(report);
|
|
600
|
+
const media = options.media ? `, ${report.media} new media file${report.media === 1 ? "" : "s"}` : "";
|
|
601
|
+
console.log(`Pulled ${report.items} item${report.items === 1 ? "" : "s"} into ${report.dir}: ${report.written.length} written, ${report.unchanged} unchanged, ${report.removed} removed${media}.`);
|
|
602
|
+
for (const path of report.written)
|
|
603
|
+
console.log(` ${path}`);
|
|
604
|
+
console.log(`The next pull fetches what changed since ${report.watermark} (kept in ${PULL_STATE_FILE}).`);
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
"content diff": {
|
|
608
|
+
usage: "content diff <sku | item-id> [--from <n>] [--to <n>]",
|
|
609
|
+
async run(args, options) {
|
|
610
|
+
if (args.length !== 1)
|
|
611
|
+
throw new CliError("Usage: content diff <sku | item-id> [--from <n>] [--to <n>]", exitCodes.usage);
|
|
612
|
+
const query = new URLSearchParams(Object.entries({ from: options.from, to: options.to }).filter((entry) => entry[1] !== undefined));
|
|
613
|
+
const diff = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/diff${query.size ? `?${query}` : ""}`);
|
|
614
|
+
if (options.format === "json")
|
|
615
|
+
return printJson(diff);
|
|
616
|
+
printContentDiff(diff);
|
|
617
|
+
},
|
|
618
|
+
},
|
|
619
|
+
"content edit": {
|
|
620
|
+
usage: "content edit <sku | item-id> [--name ...] [--subject ...] [--body ...] [--html @file] [--fields ...] [--file <path> ...] [--tool-run <id> ...] [--run <agent-run-id>] [--note <why>]",
|
|
621
|
+
async run(args, options) {
|
|
622
|
+
if (args.length !== 1)
|
|
623
|
+
throw new CliError("Usage: content edit <sku | item-id> [--body <text> | @file] [--note <why>] [...]", exitCodes.usage);
|
|
624
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
625
|
+
const input = await contentInput(options, connection);
|
|
626
|
+
if (Object.keys(input).every((k) => k === "runId" || k === "note")) {
|
|
627
|
+
throw new CliError("content edit needs something to change, such as --body, --subject or --file.", exitCodes.usage);
|
|
628
|
+
}
|
|
629
|
+
const content = await api(connection, `/api/v1/content/${encodeURIComponent(args[0])}`, input, "PATCH");
|
|
630
|
+
printContent(content, options.format);
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
"content status": {
|
|
634
|
+
usage: "content status <sku | item-id> <status> [--platform <platform> --external-id <id>]",
|
|
635
|
+
async run(args, options) {
|
|
636
|
+
if (args.length !== 2)
|
|
637
|
+
throw new CliError("Usage: content status <sku | item-id> <status> [--platform <platform> --external-id <id>]", exitCodes.usage);
|
|
638
|
+
const content = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/status`, {
|
|
639
|
+
status: args[1],
|
|
640
|
+
...(options.platform === undefined ? {} : { platform: options.platform }),
|
|
641
|
+
...(options.externalId === undefined ? {} : { externalId: options.externalId }),
|
|
642
|
+
});
|
|
643
|
+
printContent(content, options.format);
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
"content publish": {
|
|
647
|
+
usage: "content publish <sku | item-id> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <plan-id>] [--version <n>] [--run <run-id>]",
|
|
648
|
+
async run(args, options) {
|
|
649
|
+
if (args.length !== 1 || !options.to || options.target === undefined || !options.idempotencyKey) {
|
|
650
|
+
throw new CliError("Usage: content publish <sku | item-id> --to <publisher> --target '<json>' --idempotency-key <key> [--plan <plan-id>] [--version <n>] [--run <run-id>]", exitCodes.usage);
|
|
651
|
+
}
|
|
652
|
+
const version = options.version === undefined ? undefined : Number(options.version);
|
|
653
|
+
if (version !== undefined && !(Number.isInteger(version) && version >= 1))
|
|
654
|
+
throw new CliError("--version must be a whole number from 1.", exitCodes.usage);
|
|
655
|
+
const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/publish`, {
|
|
656
|
+
publisher: options.to,
|
|
657
|
+
target: await parseJson(options.target, "--target"),
|
|
658
|
+
idempotencyKey: options.idempotencyKey,
|
|
659
|
+
...(options.plan === undefined ? {} : { launchPlanId: options.plan }),
|
|
660
|
+
...(version === undefined ? {} : { version }),
|
|
661
|
+
...(options.run === undefined ? {} : { runId: options.run }),
|
|
662
|
+
});
|
|
663
|
+
printPublication(publication, options.format);
|
|
664
|
+
},
|
|
665
|
+
},
|
|
666
|
+
"content reconcile": {
|
|
667
|
+
usage: "content reconcile <publication-id>",
|
|
668
|
+
async run(args, options) {
|
|
669
|
+
if (args.length !== 1)
|
|
670
|
+
throw new CliError("Usage: content reconcile <publication-id>", exitCodes.usage);
|
|
671
|
+
const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/publications/${encodeURIComponent(args[0])}/reconcile`, {});
|
|
672
|
+
printPublication(publication, options.format);
|
|
673
|
+
},
|
|
674
|
+
},
|
|
675
|
+
"content unpublish": {
|
|
676
|
+
usage: "content unpublish <deployment-id> [--force]",
|
|
677
|
+
async run(args, options) {
|
|
678
|
+
if (args.length !== 1)
|
|
679
|
+
throw new CliError("Usage: content unpublish <deployment-id> [--force]", exitCodes.usage);
|
|
680
|
+
const { deployment } = await api(await resolveConnection(options.apiUrl), `/api/v1/deployments/${encodeURIComponent(args[0])}/unpublish`, options.force ? { force: true } : {});
|
|
681
|
+
if (options.format === "json")
|
|
682
|
+
return printJson({ deployment });
|
|
683
|
+
printFields({
|
|
684
|
+
deployment: deployment.id,
|
|
685
|
+
url: deployment.url,
|
|
686
|
+
version: deployment.version,
|
|
687
|
+
status: deployment.status,
|
|
688
|
+
unpublished: deployment.unpublishedAt,
|
|
689
|
+
by: deployment.unpublishedBy,
|
|
690
|
+
});
|
|
691
|
+
},
|
|
692
|
+
},
|
|
693
|
+
"content publish-record": {
|
|
694
|
+
usage: "content publish-record <sku | item-id> [--input '<json>' | --file <path> | < publication.json]",
|
|
695
|
+
async run(args, options) {
|
|
696
|
+
if (args.length !== 1)
|
|
697
|
+
throw new CliError("Usage: content publish-record <sku | item-id> --file <publication.json>", exitCodes.usage);
|
|
698
|
+
const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/content/${encodeURIComponent(args[0])}/publications`, await readToolInput(options));
|
|
699
|
+
printPublication(publication, options.format);
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
"content publish-finish": {
|
|
703
|
+
usage: "content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--input '<json>' | --file <path> | < outcome.json]",
|
|
704
|
+
async run(args, options) {
|
|
705
|
+
if (args.length !== 1 || !options.status) {
|
|
706
|
+
throw new CliError("Usage: content publish-finish <publication-id> --status sending|published|failed|not_sent_practice [--file <outcome.json>]", exitCodes.usage);
|
|
707
|
+
}
|
|
708
|
+
const outcome = await readToolInput(options);
|
|
709
|
+
const { publication } = await api(await resolveConnection(options.apiUrl), `/api/v1/publications/${encodeURIComponent(args[0])}`, { ...outcome, status: options.status }, "PATCH");
|
|
710
|
+
printPublication(publication, options.format);
|
|
711
|
+
},
|
|
712
|
+
},
|
|
713
|
+
"agent start": {
|
|
714
|
+
usage: "agent start",
|
|
715
|
+
async run(args, options) {
|
|
716
|
+
if (args.length > 0)
|
|
717
|
+
throw new CliError("Usage: agent start", exitCodes.usage);
|
|
718
|
+
const start = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs`, {});
|
|
719
|
+
if (options.format === "json")
|
|
720
|
+
return printJson(start);
|
|
721
|
+
const { run, quota, brandProfile } = start;
|
|
722
|
+
const rows = [
|
|
723
|
+
{ field: "run", value: run.id },
|
|
724
|
+
{ field: "resumed", value: start.resumed ? "yes" : "no" },
|
|
725
|
+
{ field: "mode", value: start.agent.mode },
|
|
726
|
+
...(start.result ? [{ field: "result", value: `${start.result.name} (${start.result.metaActionTypes.join(", ") || "no Meta source"})` }] : []),
|
|
727
|
+
...(quota ? [{ field: "ideas today", value: `${quota.creativeProposalsToday} of ${quota.ideasPerDay} used, ${quota.remainingToday} left` }] : []),
|
|
728
|
+
{ field: "brand profile", value: brandProfile ? `version ${brandProfile.version}, ${brandProfile.ageDays} days old` : "none yet" },
|
|
729
|
+
...start.memory.map((note) => ({ field: `memory ${note.key}`, value: note.value ? `${note.value.length} characters` : "empty" })),
|
|
730
|
+
];
|
|
731
|
+
console.log(renderTable(["field", "value"], rows));
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
"agent show": {
|
|
735
|
+
usage: "agent show <run-id>",
|
|
736
|
+
async run(args, options) {
|
|
737
|
+
if (args.length !== 1)
|
|
738
|
+
throw new CliError("Usage: agent show <run-id>", exitCodes.usage);
|
|
739
|
+
const shown = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs/${encodeURIComponent(args[0])}`);
|
|
740
|
+
if (options.format === "json")
|
|
741
|
+
return printJson(shown);
|
|
742
|
+
const { run, proposals } = shown;
|
|
743
|
+
console.log(renderTable(["run", "status", "startedAt", "finishedAt"], [{ ...run, run: run.id }]));
|
|
744
|
+
if (run.report)
|
|
745
|
+
console.log(`\n${run.report}`);
|
|
746
|
+
console.log(proposals.length ? `\n${renderTable(PROPOSAL_COLUMNS, proposals.map(proposalRow))}` : "\nNo proposals in this run.");
|
|
747
|
+
},
|
|
748
|
+
},
|
|
749
|
+
"agent runs": {
|
|
750
|
+
usage: "agent runs [--limit <n>]",
|
|
751
|
+
async run(_args, options) {
|
|
752
|
+
const query = options.limit ? `?limit=${encodeURIComponent(options.limit)}` : "";
|
|
753
|
+
const { runs } = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs${query}`);
|
|
754
|
+
if (options.format === "json")
|
|
755
|
+
return printJson({ runs });
|
|
756
|
+
if (runs.length === 0)
|
|
757
|
+
return console.log("No runs yet.");
|
|
758
|
+
console.log(renderTable(["id", "status", "startedAt", "finishedAt"], runs));
|
|
759
|
+
},
|
|
760
|
+
},
|
|
761
|
+
"agent finish": {
|
|
762
|
+
usage: "agent finish <run-id> --status succeeded|failed [--file report.md | < report.md]",
|
|
763
|
+
async run(args, options) {
|
|
764
|
+
if (args.length !== 1 || (options.status !== "succeeded" && options.status !== "failed")) {
|
|
765
|
+
throw new CliError("Usage: agent finish <run-id> --status succeeded|failed [--file report.md]", exitCodes.usage);
|
|
766
|
+
}
|
|
767
|
+
const report = await readText(options.file, "the report");
|
|
768
|
+
const { run } = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/runs/${encodeURIComponent(args[0])}/finish`, { status: options.status, report });
|
|
769
|
+
if (options.format === "json")
|
|
770
|
+
return printJson({ run });
|
|
771
|
+
console.log(`Finished run ${run.id} as ${run.status}.`);
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
"agent draft": {
|
|
775
|
+
usage: "agent draft --agent p3_refresh",
|
|
776
|
+
async run(args, options) {
|
|
777
|
+
if (args.length > 0)
|
|
778
|
+
throw new CliError("Usage: agent draft --agent p3_refresh", exitCodes.usage);
|
|
779
|
+
const body = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/draft`);
|
|
780
|
+
if (options.format === "json")
|
|
781
|
+
return printJson(body);
|
|
782
|
+
const { draft } = body;
|
|
783
|
+
const campaigns = (list) => list.map((c) => `${c.name} (${c.campaignId}, ${c.effectiveStatus})`).join("; ") || "none";
|
|
784
|
+
console.log(`Draft for ${draft.today}. Scan ${draft.scan.since} to ${draft.scan.until}${draft.scan.complete ? "" : " (incomplete)"}.`);
|
|
785
|
+
console.log(`P1 campaigns: ${campaigns(draft.campaigns.p1)}`);
|
|
786
|
+
console.log(`P3 campaigns: ${campaigns(draft.campaigns.p3)}`);
|
|
787
|
+
for (const flag of draft.flags)
|
|
788
|
+
console.log(`! ${flag}`);
|
|
789
|
+
const adsets = draft.adsets.map((a) => ({
|
|
790
|
+
rank: a.rank,
|
|
791
|
+
adset: a.adsetId,
|
|
792
|
+
name: a.name,
|
|
793
|
+
status: a.effectiveStatus,
|
|
794
|
+
"purchases 30d": a.d30.purchases,
|
|
795
|
+
"spend 30d": a.d30.spend,
|
|
796
|
+
"cpp 30d": a.d30.costPerPurchase,
|
|
797
|
+
"purchases 7d": a.d7.purchases,
|
|
798
|
+
today: a.today.purchases,
|
|
799
|
+
flags: [...(a.adsetId === draft.template?.adsetId ? ["template"] : []), ...a.flags].join(", "),
|
|
800
|
+
}));
|
|
801
|
+
console.log(`\n${adsets.length ? renderTable(Object.keys(adsets[0]), adsets) : "No ad sets in the purchase campaign."}`);
|
|
802
|
+
const candidate = (c) => ({
|
|
803
|
+
ad: c.sourceAdId,
|
|
804
|
+
name: excerpt(c.name, 40),
|
|
805
|
+
origin: c.origin,
|
|
806
|
+
disposition: c.disposition,
|
|
807
|
+
purchases: c.purchases,
|
|
808
|
+
spend: c.spend,
|
|
809
|
+
today: c.todayPurchases,
|
|
810
|
+
post: c.postId,
|
|
811
|
+
"in P3": c.p3Matches.map((m) => `${m.adId} ${m.status} by ${m.matchedBy}`).join("; "),
|
|
812
|
+
});
|
|
813
|
+
const rows = [...draft.candidates, ...draft.carriedForward].map(candidate);
|
|
814
|
+
console.log(`\n${rows.length ? renderTable(Object.keys(rows[0]), rows) : "No P1 winners."}`);
|
|
815
|
+
},
|
|
816
|
+
},
|
|
817
|
+
"memory get": {
|
|
818
|
+
usage: "memory get [baselines|watch_items|changes_log|gotchas]",
|
|
819
|
+
async run(args, options) {
|
|
820
|
+
if (args.length > 1)
|
|
821
|
+
throw new CliError("Usage: memory get [<note>]", exitCodes.usage);
|
|
822
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
823
|
+
if (args[0]) {
|
|
824
|
+
const { note } = await api(connection, `${agentPath(options)}/memory/${encodeURIComponent(args[0])}`);
|
|
825
|
+
if (options.format === "json")
|
|
826
|
+
return printJson({ note });
|
|
827
|
+
return void process.stdout.write(note.value ? `${note.value.replace(/\n?$/, "\n")}` : "");
|
|
828
|
+
}
|
|
829
|
+
const { memory } = await api(connection, `${agentPath(options)}/memory`);
|
|
830
|
+
if (options.format === "json")
|
|
831
|
+
return printJson({ memory });
|
|
832
|
+
console.log(memory.map((n) => `## ${n.key}${n.updatedAt ? ` (updated ${n.updatedAt})` : ""}\n\n${n.value || "(empty)"}`).join("\n\n"));
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
"memory set": {
|
|
836
|
+
usage: "memory set <note> [--file <path> | < note.md]",
|
|
837
|
+
async run(args, options) {
|
|
838
|
+
if (args.length !== 1)
|
|
839
|
+
throw new CliError("Usage: memory set <note> [--file <path>]", exitCodes.usage);
|
|
840
|
+
const value = await readText(options.file, "the note");
|
|
841
|
+
const { note } = await api(await resolveConnection(options.apiUrl), `${agentPath(options)}/memory/${encodeURIComponent(args[0])}`, { value }, "PUT");
|
|
842
|
+
if (options.format === "json")
|
|
843
|
+
return printJson({ note });
|
|
844
|
+
console.log(`Saved ${note.key} (${note.value.length} characters).`);
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
"brand get": {
|
|
848
|
+
usage: "brand get",
|
|
849
|
+
async run(_args, options) {
|
|
850
|
+
const { profile } = await api(await resolveConnection(options.apiUrl), "/api/v1/brand-profile");
|
|
851
|
+
if (options.format === "json")
|
|
852
|
+
return printJson({ profile });
|
|
853
|
+
if (!profile)
|
|
854
|
+
return console.log("No brand profile yet.");
|
|
855
|
+
console.error(`Brand profile version ${profile.version}, saved ${profile.createdAt}.`);
|
|
856
|
+
process.stdout.write(profile.markdown.replace(/\n?$/, "\n"));
|
|
857
|
+
},
|
|
858
|
+
},
|
|
859
|
+
"brand set": {
|
|
860
|
+
usage: "brand set [--file <path> | < profile.md] [--sources '<json>' | @file.json]",
|
|
861
|
+
async run(args, options) {
|
|
862
|
+
if (args.length > 0)
|
|
863
|
+
throw new CliError("Usage: brand set [--file <path>] [--sources '<json>' | @file.json]", exitCodes.usage);
|
|
864
|
+
const markdown = await readText(options.file, "the brand profile");
|
|
865
|
+
const sources = options.sources === undefined ? [] : await parseJson(options.sources, "--sources");
|
|
866
|
+
const { profile } = await api(await resolveConnection(options.apiUrl), "/api/v1/brand-profile", { markdown, sources }, "PUT");
|
|
867
|
+
if (options.format === "json")
|
|
868
|
+
return printJson({ profile });
|
|
869
|
+
console.log(`Saved brand profile version ${profile.version}.`);
|
|
870
|
+
},
|
|
871
|
+
},
|
|
872
|
+
"content add-page": {
|
|
873
|
+
usage: "content add-page <url>",
|
|
874
|
+
async run(args, options) {
|
|
875
|
+
if (args.length !== 1)
|
|
876
|
+
throw new CliError("Usage: content add-page <url>", exitCodes.usage);
|
|
877
|
+
const { page } = await api(await resolveConnection(options.apiUrl), "/api/v1/content/pages", { url: args[0] });
|
|
878
|
+
if (options.format === "json")
|
|
879
|
+
return printJson({ page });
|
|
880
|
+
console.log(renderTable(["key", "status", "version", "changed", "headline", "blocks"], [{ ...page, blocks: page.blockCount }]));
|
|
881
|
+
for (const line of [page.error, ...page.notes])
|
|
882
|
+
if (line)
|
|
883
|
+
console.error(line);
|
|
884
|
+
},
|
|
885
|
+
},
|
|
886
|
+
"content track-link": {
|
|
887
|
+
usage: "content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>]",
|
|
888
|
+
async run(args, options) {
|
|
889
|
+
if (args.length !== 2)
|
|
890
|
+
throw new CliError("Usage: content track-link <sku> <url> [--channel facebook|email|sms|web] [--campaign <id>]", exitCodes.usage);
|
|
891
|
+
const { url } = await api(await resolveConnection(options.apiUrl), "/api/v1/content/track-link", {
|
|
892
|
+
sku: args[0],
|
|
893
|
+
url: args[1],
|
|
894
|
+
channel: options.channel,
|
|
895
|
+
campaign: options.campaign,
|
|
896
|
+
});
|
|
897
|
+
if (options.format === "json")
|
|
898
|
+
return printJson({ url });
|
|
899
|
+
console.log(url);
|
|
900
|
+
},
|
|
901
|
+
},
|
|
902
|
+
trail: {
|
|
903
|
+
usage: "trail <plan-id | sku | item-id>",
|
|
904
|
+
async run(args, options) {
|
|
905
|
+
if (args.length !== 1)
|
|
906
|
+
throw new CliError("Usage: trail <plan-id | sku | item-id>", exitCodes.usage);
|
|
907
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
908
|
+
const ref = args[0];
|
|
909
|
+
const trailOf = (query) => api(connection, `/api/v1/trail?${query}=${encodeURIComponent(ref)}`);
|
|
910
|
+
// Plans and items both have uuid ids: an id that names no plan is read as an item.
|
|
911
|
+
const { trail } = UUID.test(ref)
|
|
912
|
+
? await trailOf("plan").catch((error) => {
|
|
913
|
+
if (error instanceof ApiError && error.code === "launch_plan_not_found")
|
|
914
|
+
return trailOf("item");
|
|
915
|
+
throw error;
|
|
916
|
+
})
|
|
917
|
+
: await trailOf("item");
|
|
918
|
+
printTrail(trail, options.format);
|
|
919
|
+
},
|
|
920
|
+
},
|
|
921
|
+
"plans create": {
|
|
922
|
+
usage: "plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>' | @file.json] [--landing <sku | item-id> | --landing-url <url>] [--run <agent-run-id>]",
|
|
923
|
+
async run(args, options) {
|
|
924
|
+
if (args.length > 0 || !options.name || !options.goal || !options.channel) {
|
|
925
|
+
throw new CliError("Usage: plans create --name <name> --goal <text> --channel meta|ghl|klaviyo|web [--target '<json>'] [--landing <sku> | --landing-url <url>]", exitCodes.usage);
|
|
926
|
+
}
|
|
927
|
+
const { plan } = await api(await resolveConnection(options.apiUrl), "/api/v1/launch-plans", {
|
|
928
|
+
name: options.name,
|
|
929
|
+
goal: await textOrFile(options.goal),
|
|
930
|
+
channel: options.channel,
|
|
931
|
+
...(options.target === undefined ? {} : { target: await parseJson(options.target, "--target") }),
|
|
932
|
+
...(options.landing === undefined ? {} : { landingItem: options.landing }),
|
|
933
|
+
...(options.landingUrl === undefined ? {} : { landingUrl: options.landingUrl }),
|
|
934
|
+
...(options.run === undefined ? {} : { runId: options.run }),
|
|
935
|
+
});
|
|
936
|
+
printPlan(plan, options.format);
|
|
937
|
+
},
|
|
938
|
+
},
|
|
939
|
+
"plans show": {
|
|
940
|
+
usage: "plans show <plan-id>",
|
|
941
|
+
async run(args, options) {
|
|
942
|
+
if (args.length !== 1)
|
|
943
|
+
throw new CliError("Usage: plans show <plan-id>", exitCodes.usage);
|
|
944
|
+
const { plan } = await api(await resolveConnection(options.apiUrl), planPath(args[0]));
|
|
945
|
+
printPlan(plan, options.format);
|
|
946
|
+
},
|
|
947
|
+
},
|
|
948
|
+
"plans add": {
|
|
949
|
+
usage: "plans add <plan-id> <sku | item-id> [--role <role>] [--position <n>]",
|
|
950
|
+
async run(args, options) {
|
|
951
|
+
if (args.length !== 2)
|
|
952
|
+
throw new CliError("Usage: plans add <plan-id> <sku | item-id> [--role <role>] [--position <n>]", exitCodes.usage);
|
|
953
|
+
const position = options.position === undefined ? undefined : Number(options.position);
|
|
954
|
+
if (position !== undefined && (!Number.isInteger(position) || position < 0))
|
|
955
|
+
throw new CliError("--position must be a whole number from 0.", exitCodes.usage);
|
|
956
|
+
const { plan } = await api(await resolveConnection(options.apiUrl), `${planPath(args[0])}/items`, {
|
|
957
|
+
item: args[1],
|
|
958
|
+
...(options.role === undefined ? {} : { role: options.role }),
|
|
959
|
+
...(position === undefined ? {} : { position }),
|
|
960
|
+
});
|
|
961
|
+
printPlan(plan, options.format);
|
|
962
|
+
},
|
|
963
|
+
},
|
|
964
|
+
"plans remove": {
|
|
965
|
+
usage: "plans remove <plan-id> <sku | item-id>",
|
|
966
|
+
async run(args, options) {
|
|
967
|
+
if (args.length !== 2)
|
|
968
|
+
throw new CliError("Usage: plans remove <plan-id> <sku | item-id>", exitCodes.usage);
|
|
969
|
+
const path = `${planPath(args[0])}/items?item=${encodeURIComponent(args[1])}`;
|
|
970
|
+
const { plan } = await api(await resolveConnection(options.apiUrl), path, undefined, "DELETE");
|
|
971
|
+
printPlan(plan, options.format);
|
|
972
|
+
},
|
|
973
|
+
},
|
|
974
|
+
"plans status": {
|
|
975
|
+
usage: "plans status <plan-id> <status>",
|
|
976
|
+
async run(args, options) {
|
|
977
|
+
if (args.length !== 2)
|
|
978
|
+
throw new CliError("Usage: plans status <plan-id> draft|ready|publishing|live|done|cancelled", exitCodes.usage);
|
|
979
|
+
const { plan } = await api(await resolveConnection(options.apiUrl), planPath(args[0]), { status: args[1] }, "PATCH");
|
|
980
|
+
printPlan(plan, options.format);
|
|
981
|
+
},
|
|
982
|
+
},
|
|
983
|
+
"proposals create": {
|
|
984
|
+
usage: "proposals create --input '<json>' | --file <path> | < proposal.json",
|
|
985
|
+
async run(args, options) {
|
|
986
|
+
if (args.length > 0)
|
|
987
|
+
throw new CliError("Usage: proposals create --input '<json>'", exitCodes.usage);
|
|
988
|
+
const { proposal } = await api(await resolveConnection(options.apiUrl), "/api/v1/proposals", await readToolInput(options));
|
|
989
|
+
if (options.format === "json")
|
|
990
|
+
return printJson({ proposal });
|
|
991
|
+
console.log(renderTable(PROPOSAL_COLUMNS, [proposalRow(proposal)]));
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
"proposals list": {
|
|
995
|
+
usage: "proposals list [--status pending|approved|rejected] [--kind <kind>] [--run <run-id>] [--limit <n>]",
|
|
996
|
+
async run(_args, options) {
|
|
997
|
+
const query = new URLSearchParams();
|
|
998
|
+
if (options.status)
|
|
999
|
+
query.set("status", options.status);
|
|
1000
|
+
if (options.kind)
|
|
1001
|
+
query.set("kind", options.kind);
|
|
1002
|
+
if (options.run)
|
|
1003
|
+
query.set("runId", options.run);
|
|
1004
|
+
if (options.limit)
|
|
1005
|
+
query.set("limit", options.limit);
|
|
1006
|
+
const path = `/api/v1/proposals${query.size ? `?${query}` : ""}`;
|
|
1007
|
+
const { proposals } = await api(await resolveConnection(options.apiUrl), path);
|
|
1008
|
+
if (options.format === "json")
|
|
1009
|
+
return printJson({ proposals });
|
|
1010
|
+
if (proposals.length === 0)
|
|
1011
|
+
return console.log("No proposals.");
|
|
1012
|
+
console.log(renderTable(PROPOSAL_COLUMNS, proposals.map(proposalRow)));
|
|
1013
|
+
},
|
|
1014
|
+
},
|
|
1015
|
+
"skill publish": {
|
|
1016
|
+
usage: "skill publish <dir> [--notes <text>] [--harness claude,codex]",
|
|
1017
|
+
async run(args, options) {
|
|
1018
|
+
if (args.length !== 1)
|
|
1019
|
+
throw new CliError("Usage: skill publish <dir> [--notes <text>] [--harness claude,codex]", exitCodes.usage);
|
|
1020
|
+
const dir = resolve(args[0]);
|
|
1021
|
+
const name = basename(dir);
|
|
1022
|
+
if (!SKILL_NAME_PATTERN.test(name)) {
|
|
1023
|
+
throw new CliError(`The folder's name is the skill's name, and '${name}' is not one: use 1 to 64 lowercase letters, digits and dashes.`, exitCodes.usage);
|
|
1024
|
+
}
|
|
1025
|
+
if (isPlaybook(name)) {
|
|
1026
|
+
throw new CliError(`'${name}' is a built-in playbook's name, and \`${brand.slug} skill install ${name}\` always installs the built-in one. Rename the folder.`, exitCodes.usage);
|
|
1027
|
+
}
|
|
1028
|
+
const zip = packSkill(name, await readSkillFolder(dir));
|
|
1029
|
+
const query = new URLSearchParams();
|
|
1030
|
+
if (options.notes !== undefined)
|
|
1031
|
+
query.set("notes", options.notes);
|
|
1032
|
+
if (options.harness !== undefined)
|
|
1033
|
+
query.set("harnesses", options.harness);
|
|
1034
|
+
const path = `/api/operator/skills/${name}/versions${query.size ? `?${query}` : ""}`;
|
|
1035
|
+
const published = await apiUpload(await operatorConnection(options.apiUrl), path, zip, "application/zip");
|
|
1036
|
+
if (options.format === "json")
|
|
1037
|
+
return printJson(published);
|
|
1038
|
+
console.log(`${published.name} version ${published.version}`);
|
|
1039
|
+
console.log(`sha256 ${published.sha256}`);
|
|
1040
|
+
},
|
|
1041
|
+
},
|
|
1042
|
+
"skill list": {
|
|
1043
|
+
usage: "skill list",
|
|
1044
|
+
async run(args, options) {
|
|
1045
|
+
if (args.length > 0)
|
|
1046
|
+
throw new CliError("Usage: skill list", exitCodes.usage);
|
|
1047
|
+
const { skills } = await api(await operatorConnection(options.apiUrl), "/api/operator/skills");
|
|
1048
|
+
if (options.format === "json")
|
|
1049
|
+
return printJson({ skills });
|
|
1050
|
+
if (skills.length === 0)
|
|
1051
|
+
return console.log("No skills in the library.");
|
|
1052
|
+
const rows = skills.flatMap((s) => s.versions.map((v) => ({ skill: s.name, version: v.version, status: v.status, published: v.createdAt, sha256: v.sha256.slice(0, 12), notes: v.notes })));
|
|
1053
|
+
console.log(renderTable(["skill", "version", "status", "published", "sha256", "notes"], rows));
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
1056
|
+
"skill install": {
|
|
1057
|
+
usage: "skill install [meta-ads|p3-refresh|<name>[@<version>]] [--project] [--codex]",
|
|
1058
|
+
async run(args, options) {
|
|
1059
|
+
const usage = "Usage: skill install [meta-ads|p3-refresh|<name>[@<version>]] [--project] [--codex]";
|
|
1060
|
+
const [which] = args;
|
|
1061
|
+
if (args.length > 1)
|
|
1062
|
+
throw new CliError(usage, exitCodes.usage);
|
|
1063
|
+
if (which !== undefined && !isPlaybook(which))
|
|
1064
|
+
return installLibrarySkill(parseSkillRef(which), options);
|
|
1065
|
+
const playbook = which === undefined ? null : playbooks[which];
|
|
1066
|
+
if (options.codex) {
|
|
1067
|
+
if (which === "meta-ads")
|
|
1068
|
+
return installAgentsMd(join(process.cwd(), "AGENTS.md"));
|
|
1069
|
+
if (!playbook)
|
|
1070
|
+
throw new CliError("--codex installs a playbook: skill install meta-ads|p3-refresh --codex", exitCodes.usage);
|
|
1071
|
+
if (options.project)
|
|
1072
|
+
throw new CliError(`--codex installs ${playbook.name} for every Codex run, under CODEX_HOME; drop --project`, exitCodes.usage);
|
|
1073
|
+
const path = await writeSkill(codexSkillsDir(), playbook.name, playbook.skill, []);
|
|
1074
|
+
return console.log(`Installed the ${playbook.name} skill for Codex to ${path}.`);
|
|
1075
|
+
}
|
|
1076
|
+
const [name, content] = playbook ? [playbook.name, playbook.skill] : [brand.slug, skill];
|
|
1077
|
+
const { skillsDir, above } = claudeSkillsDir(options.project);
|
|
1078
|
+
const path = await writeSkill(skillsDir, name, content, above);
|
|
1079
|
+
console.log(`Installed the ${name} skill to ${path}.`);
|
|
1080
|
+
},
|
|
1081
|
+
},
|
|
1082
|
+
"worker list": {
|
|
1083
|
+
usage: "worker list",
|
|
1084
|
+
async run(args, options) {
|
|
1085
|
+
if (args.length > 0)
|
|
1086
|
+
throw new CliError("Usage: worker list", exitCodes.usage);
|
|
1087
|
+
const { workers } = await api(await resolveConnection(options.apiUrl), "/api/v1/workers");
|
|
1088
|
+
if (options.format === "json")
|
|
1089
|
+
return printJson({ workers });
|
|
1090
|
+
if (workers.length === 0)
|
|
1091
|
+
return console.log("No workers yet.");
|
|
1092
|
+
const rows = workers.map((w) => ({
|
|
1093
|
+
id: w.id,
|
|
1094
|
+
skill: w.skill,
|
|
1095
|
+
version: w.candidateVersion === null ? w.version : `${w.version} (trying ${w.candidateVersion})`,
|
|
1096
|
+
schedule: `${w.schedule} ${w.timeZone}`,
|
|
1097
|
+
mode: w.mode,
|
|
1098
|
+
runner: w.runner,
|
|
1099
|
+
enabled: w.enabled ? "yes" : "no",
|
|
1100
|
+
"next run": w.nextRunAt,
|
|
1101
|
+
"last run": w.lastRun ? `${w.lastRun.status}${w.lastRun.finishedAt ? ` ${w.lastRun.finishedAt}` : ""}` : "",
|
|
1102
|
+
}));
|
|
1103
|
+
console.log(renderTable(["id", "skill", "version", "schedule", "mode", "runner", "enabled", "next run", "last run"], rows));
|
|
1104
|
+
},
|
|
1105
|
+
},
|
|
1106
|
+
"worker runs": {
|
|
1107
|
+
usage: "worker runs <worker-id | skill> [--limit <n>]",
|
|
1108
|
+
async run(args, options) {
|
|
1109
|
+
if (args.length !== 1)
|
|
1110
|
+
throw new CliError("Usage: worker runs <worker-id | skill> [--limit <n>]", exitCodes.usage);
|
|
1111
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
1112
|
+
const id = await workerId(connection, args[0]);
|
|
1113
|
+
const query = options.limit === undefined ? "" : `?limit=${encodeURIComponent(options.limit)}`;
|
|
1114
|
+
const { runs } = await api(connection, `/api/v1/workers/${encodeURIComponent(id)}/runs${query}`);
|
|
1115
|
+
if (options.format === "json")
|
|
1116
|
+
return printJson({ runs });
|
|
1117
|
+
if (runs.length === 0)
|
|
1118
|
+
return console.log("No runs yet.");
|
|
1119
|
+
console.log(renderTable(["id", "version", "status", "queued", "finished", "duration", "ai", "tools", "compute", "error", "report"], runs.map(workerRunRow)));
|
|
1120
|
+
},
|
|
1121
|
+
},
|
|
1122
|
+
"worker run-now": {
|
|
1123
|
+
usage: "worker run-now <worker-id | skill>",
|
|
1124
|
+
async run(args, options) {
|
|
1125
|
+
if (args.length !== 1)
|
|
1126
|
+
throw new CliError("Usage: worker run-now <worker-id | skill>", exitCodes.usage);
|
|
1127
|
+
const connection = await resolveConnection(options.apiUrl);
|
|
1128
|
+
const id = await workerId(connection, args[0]);
|
|
1129
|
+
const queued = await api(connection, `/api/v1/workers/${encodeURIComponent(id)}/run-now`, {});
|
|
1130
|
+
if (options.format === "json")
|
|
1131
|
+
return printJson(queued);
|
|
1132
|
+
console.log(`Queued run ${queued.run.id} (version ${queued.run.version}).`);
|
|
1133
|
+
if (queued.shadow)
|
|
1134
|
+
console.log(`Queued shadow run ${queued.shadow.id} (version ${queued.shadow.version}, practice mode) beside it.`);
|
|
1135
|
+
},
|
|
1136
|
+
},
|
|
1137
|
+
"worker promote": {
|
|
1138
|
+
usage: "worker promote <skill> --workspace <slug> (operator token)",
|
|
1139
|
+
async run(args, options) {
|
|
1140
|
+
const { promoted } = await changeCandidate("promote", args, options);
|
|
1141
|
+
if (options.format === "json")
|
|
1142
|
+
return printJson({ promoted });
|
|
1143
|
+
console.log(`${promoted.skill} version ${promoted.version} is now in use on ${promoted.moved} worker${promoted.moved === 1 ? "" : "s"}.`);
|
|
1144
|
+
},
|
|
1145
|
+
},
|
|
1146
|
+
"worker drop": {
|
|
1147
|
+
usage: "worker drop <skill> --workspace <slug> (operator token)",
|
|
1148
|
+
async run(args, options) {
|
|
1149
|
+
const { dropped } = await changeCandidate("drop", args, options);
|
|
1150
|
+
if (options.format === "json")
|
|
1151
|
+
return printJson({ dropped });
|
|
1152
|
+
console.log(`Stopped trying ${dropped.skill} version ${dropped.version} on ${dropped.workspace}. Runs stay on version ${dropped.versionInUse}.`);
|
|
1153
|
+
},
|
|
1154
|
+
},
|
|
1155
|
+
"runner once": {
|
|
1156
|
+
usage: `runner once [--run <id>] [--max-seconds <n>] (takes the runner token in ${RUNNER_TOKEN_ENV})`,
|
|
1157
|
+
async run(args, options) {
|
|
1158
|
+
if (args.length > 0)
|
|
1159
|
+
throw new CliError("Usage: runner once [--run <id>] [--max-seconds <n>]", exitCodes.usage);
|
|
1160
|
+
await withRunner(options, async (runner) => {
|
|
1161
|
+
const outcome = await runOnce({ ...runner, runId: options.run });
|
|
1162
|
+
if (outcome.kind === "idle")
|
|
1163
|
+
console.log("No run is queued.");
|
|
1164
|
+
else if (outcome.status !== "succeeded")
|
|
1165
|
+
process.exitCode = exitCodes.error;
|
|
1166
|
+
});
|
|
1167
|
+
},
|
|
1168
|
+
},
|
|
1169
|
+
runner: {
|
|
1170
|
+
usage: `runner [--max-seconds <n>] (does queued runs one at a time until stopped)`,
|
|
1171
|
+
async run(args, options) {
|
|
1172
|
+
if (args.length > 0 || options.run !== undefined)
|
|
1173
|
+
throw new CliError("Usage: runner [--max-seconds <n>]", exitCodes.usage);
|
|
1174
|
+
await withRunner(options, runLoop);
|
|
1175
|
+
},
|
|
1176
|
+
},
|
|
1177
|
+
docs: {
|
|
1178
|
+
usage: "docs [meta-ads|p3-refresh]",
|
|
1179
|
+
async run(args) {
|
|
1180
|
+
const [which] = args;
|
|
1181
|
+
if (args.length > 1 || (which !== undefined && !isPlaybook(which)))
|
|
1182
|
+
throw new CliError("Usage: docs [meta-ads|p3-refresh]", exitCodes.usage);
|
|
1183
|
+
process.stdout.write(which === undefined ? guide : playbooks[which].text);
|
|
1184
|
+
},
|
|
1185
|
+
},
|
|
1186
|
+
};
|
|
1187
|
+
/** Runs `work` with the runner's settings from the environment; SIGINT or SIGTERM stops the run in hand, reported failed. */
|
|
1188
|
+
async function withRunner(options, work) {
|
|
1189
|
+
const token = process.env[RUNNER_TOKEN_ENV];
|
|
1190
|
+
if (!token)
|
|
1191
|
+
throw new CliError(`Set ${RUNNER_TOKEN_ENV} to this machine's runner token.`, exitCodes.auth);
|
|
1192
|
+
const stop = new AbortController();
|
|
1193
|
+
const onSignal = () => stop.abort();
|
|
1194
|
+
process.on("SIGINT", onSignal);
|
|
1195
|
+
process.on("SIGTERM", onSignal);
|
|
1196
|
+
try {
|
|
1197
|
+
await work({
|
|
1198
|
+
apiUrl: resolveApiUrl(options.apiUrl, null),
|
|
1199
|
+
token,
|
|
1200
|
+
harnessCommand: process.env[HARNESS_COMMAND_ENV] || undefined,
|
|
1201
|
+
maxSeconds: options.maxSeconds,
|
|
1202
|
+
signal: stop.signal,
|
|
1203
|
+
say: (line) => console.log(line),
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
finally {
|
|
1207
|
+
process.off("SIGINT", onSignal);
|
|
1208
|
+
process.off("SIGTERM", onSignal);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
const playbooks = {
|
|
1212
|
+
"meta-ads": { name: META_ADS_SKILL_NAME, skill: metaAdsSkill, text: metaAdsPlaybook },
|
|
1213
|
+
"p3-refresh": { name: P3_REFRESH_SKILL_NAME, skill: p3RefreshSkill, text: p3RefreshPlaybook },
|
|
1214
|
+
};
|
|
1215
|
+
const isPlaybook = (name) => Object.hasOwn(playbooks, name);
|
|
1216
|
+
const codexSkillsDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "skills");
|
|
1217
|
+
/** Claude Code's skills folder, in the user's home or (with --project) the current folder. */
|
|
1218
|
+
function claudeSkillsDir(project) {
|
|
1219
|
+
const root = project ? process.cwd() : homedir();
|
|
1220
|
+
// A cloned repository could point its .claude or .claude/skills elsewhere; the user's own home is trusted.
|
|
1221
|
+
const above = project ? [join(root, ".claude"), join(root, ".claude", "skills")] : [];
|
|
1222
|
+
return { skillsDir: join(root, ".claude", "skills"), above };
|
|
1223
|
+
}
|
|
1224
|
+
/** Downloads one version from the skill library, checks it, and makes it the installed copy. */
|
|
1225
|
+
async function installLibrarySkill(ref, options) {
|
|
1226
|
+
if (options.codex && options.project)
|
|
1227
|
+
throw new CliError("--codex installs a skill for every Codex run, under CODEX_HOME; drop --project", exitCodes.usage);
|
|
1228
|
+
const query = ref.version === null ? "" : `?version=${ref.version}`;
|
|
1229
|
+
const download = await api(await operatorConnection(options.apiUrl), `/api/operator/skills/${ref.name}/download${query}`);
|
|
1230
|
+
const files = unpackSkill(download, await getSigned(download.url));
|
|
1231
|
+
const { skillsDir, above } = options.codex ? { skillsDir: codexSkillsDir(), above: [] } : claudeSkillsDir(options.project);
|
|
1232
|
+
const path = await installSkillFolder(skillsDir, download.name, files, above);
|
|
1233
|
+
console.log(`installed ${download.name} version ${download.version} to ${path}`);
|
|
1234
|
+
}
|
|
1235
|
+
/** Writes <skillsDir>/<name>/SKILL.md, refusing to follow a symlink at any of `above` or the skill's own paths. */
|
|
1236
|
+
async function writeSkill(skillsDir, name, content, above) {
|
|
1237
|
+
const path = await inRunFolder(join(skillsDir, name, "SKILL.md"));
|
|
1238
|
+
const linked = await firstSymlink([...above, join(skillsDir, name), path]);
|
|
1239
|
+
if (linked)
|
|
1240
|
+
throw new CliError(`Refusing to write through the symlink at ${linked}. Remove it and try again.`, exitCodes.error);
|
|
1241
|
+
await mkdir(dirname(path), { recursive: true });
|
|
1242
|
+
await writeFileAtomic(path, content, 0o644);
|
|
1243
|
+
return path;
|
|
1244
|
+
}
|
|
1245
|
+
const SOURCE_RUNS_SHOWN = 10;
|
|
1246
|
+
/** What the source is retrying: "the whole sync since <time>", and each failing part as "<part> since <time>". */
|
|
1247
|
+
/** The source's friendly name; a source the platform or AI has not named yet shows as its connector, never its "default" key. */
|
|
1248
|
+
function sourceLabel(source) {
|
|
1249
|
+
return source.displayName ?? (source.name === "default" ? (source.connectorName ?? source.connector) : source.name);
|
|
1250
|
+
}
|
|
1251
|
+
function retryingText(source) {
|
|
1252
|
+
const whole = source.failingSince ? [`the whole sync since ${source.failingSince}`] : [];
|
|
1253
|
+
return [...whole, ...(source.failingParts ?? []).map((p) => `${p.part} since ${p.since}`)].join(", ");
|
|
1254
|
+
}
|
|
1255
|
+
function historyText(history) {
|
|
1256
|
+
if (history === null)
|
|
1257
|
+
return "nothing left to fetch";
|
|
1258
|
+
const main = history.doneSince === null ? `not started, back to ${history.targetSince}` : `fetched back to ${history.doneSince}, of ${history.targetSince}`;
|
|
1259
|
+
const catchingUp = Object.entries(history.catchUp ?? {}).map(([part, range]) => `; ${part} fetching again, ${range.until} back to ${range.since}`);
|
|
1260
|
+
return main + catchingUp.join("");
|
|
1261
|
+
}
|
|
1262
|
+
const FUNNEL_NUMBERS = ["spend", "leads", "booked_calls", "calls_held", "sales_count", "revenue_net", "cost_per_lead", "cost_per_booked_call", "cost_per_sale", "roas"];
|
|
1263
|
+
const MONEY_NUMBERS = ["gross", "refunds", "net", "orders", "ad_spend", "affiliate_payouts", "left_after_ad_costs", "roas"];
|
|
1264
|
+
const CAMPAIGN_NUMBERS = [
|
|
1265
|
+
"spend",
|
|
1266
|
+
"leads_first_touch",
|
|
1267
|
+
"leads_last_touch",
|
|
1268
|
+
"booked_calls_first_touch",
|
|
1269
|
+
"sales_first_touch",
|
|
1270
|
+
"revenue_first_touch",
|
|
1271
|
+
"revenue_last_touch",
|
|
1272
|
+
"cost_per_lead_first_touch",
|
|
1273
|
+
"roas_first_touch",
|
|
1274
|
+
"roas_last_touch",
|
|
1275
|
+
];
|
|
1276
|
+
function reportQuery(options, extra) {
|
|
1277
|
+
const query = new URLSearchParams();
|
|
1278
|
+
for (const [name, value] of Object.entries({ from: options.from, to: options.to, ...extra }))
|
|
1279
|
+
if (value !== undefined)
|
|
1280
|
+
query.set(name, value);
|
|
1281
|
+
return query.size ? `?${query}` : "";
|
|
1282
|
+
}
|
|
1283
|
+
/** The rows, then the totals, which are one row per currency. */
|
|
1284
|
+
function printReport(title, report, labels, numbers) {
|
|
1285
|
+
if (report.rows.length === 0)
|
|
1286
|
+
return console.log(`No numbers from ${report.from} to ${report.to}.`);
|
|
1287
|
+
console.log(`${title}, ${report.from} to ${report.to}:`);
|
|
1288
|
+
console.log(renderTable([...labels, ...numbers], report.rows));
|
|
1289
|
+
console.log("\nTotals:");
|
|
1290
|
+
console.log(renderTable(["currency", ...numbers], report.totals));
|
|
1291
|
+
}
|
|
1292
|
+
const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
|
|
1293
|
+
const PROPOSAL_COLUMNS = ["id", "kind", "status", "target", "change", "reason"];
|
|
1294
|
+
function proposalChange(p) {
|
|
1295
|
+
if (p.kind === "creative")
|
|
1296
|
+
return p.creative?.assetUrl ?? "";
|
|
1297
|
+
if (p.kind === "p3_refresh_batch") {
|
|
1298
|
+
const { retireAdsetId, snapshot } = p.payload;
|
|
1299
|
+
return `${snapshot?.roster?.length ?? 0} ads, ${retireAdsetId ? `retire ${retireAdsetId}` : "retire none"}`;
|
|
1300
|
+
}
|
|
1301
|
+
return typeof p.payload.changePercent === "number" ? `${p.payload.changePercent}%` : "pause";
|
|
1302
|
+
}
|
|
1303
|
+
function proposalRow(p) {
|
|
1304
|
+
return { id: p.id, kind: p.kind, status: p.status, target: p.creativeSku ?? Object.values(p.target).join(" "), change: proposalChange(p), reason: excerpt(p.reason, 80) };
|
|
1305
|
+
}
|
|
1306
|
+
// Codex reads AGENTS.md from the working directory; one this CLI did not write is never replaced.
|
|
1307
|
+
async function installAgentsMd(path) {
|
|
1308
|
+
await inRunFolder(path);
|
|
1309
|
+
const linked = await firstSymlink([path]);
|
|
1310
|
+
if (linked)
|
|
1311
|
+
throw new CliError(`Refusing to write through the symlink at ${linked}. Remove it and try again.`, exitCodes.error);
|
|
1312
|
+
const existing = await readFile(path, "utf8").catch((error) => {
|
|
1313
|
+
if (error.code === "ENOENT")
|
|
1314
|
+
return null;
|
|
1315
|
+
throw error;
|
|
1316
|
+
});
|
|
1317
|
+
if (existing !== null && !existing.startsWith(AGENTS_MD_MARKER)) {
|
|
1318
|
+
throw new CliError(`${path} already exists and was not written by ${brand.slug}. Move it, or run Codex from another directory.`, exitCodes.error);
|
|
1319
|
+
}
|
|
1320
|
+
await writeFileAtomic(path, metaAdsAgentsMd, 0o644);
|
|
1321
|
+
console.log(`Installed the meta-ads playbook for Codex to ${path}.`);
|
|
1322
|
+
}
|
|
1323
|
+
const help = `${brand.name} CLI: read-only SQL on your marketing data warehouse, generation tools, and the creative ledger.
|
|
1324
|
+
|
|
1325
|
+
Usage:
|
|
1326
|
+
${Object.values(commands)
|
|
1327
|
+
.map((c) => ` ${brand.slug} ${c.usage}`)
|
|
1328
|
+
.join("\n")}
|
|
1329
|
+
|
|
1330
|
+
Options:
|
|
1331
|
+
--format table|json output format (default: table)
|
|
1332
|
+
--api-url <url> API base URL
|
|
1333
|
+
--wait <seconds> how long tools run and tools get wait for a run (default: ${DEFAULT_WAIT_SECONDS}; 0 returns at once)
|
|
1334
|
+
--agent <kind> which agent the agent and memory commands use: meta_ads (default) or p3_refresh; in a worker run, skill
|
|
1335
|
+
(the run's skill's agent) is the default
|
|
1336
|
+
--kind <kind> what sends to a new intake address: generic (default), ghl_workflow, aloware, webinarfuel, stealth_seminar or mailgun
|
|
1337
|
+
--retention-days <n> how many days intake create keeps each event (default: 90)
|
|
1338
|
+
--signing-key-stdin read the sender's signing key for intake create or intake signing-key from stdin (mailgun needs one)
|
|
1339
|
+
--table <name> the table import csv loads the file into, or import remove drops, as import_<name>;
|
|
1340
|
+
for warehouse schema, a table to describe in full; repeat for several
|
|
1341
|
+
--summary warehouse schema: one short row per table (kind, source, column count, summary)
|
|
1342
|
+
--search <words> warehouse schema: describe in full the tables whose name, description or columns mention a word
|
|
1343
|
+
--out <path> the file, or existing folder, creatives download saves to (default: the current folder)
|
|
1344
|
+
--open open the file creatives download saved
|
|
1345
|
+
--media with content pull, also save each item's files and HTML under media/ in the folder
|
|
1346
|
+
--full with content pull, fetch every item, not only what changed since the last pull (brings new numbers)
|
|
1347
|
+
--force content unpublish: take the page down even while delivering Meta ads link to it
|
|
1348
|
+
--yes confirm intake delete or import remove, which delete saved data for good
|
|
1349
|
+
--from <YYYY-MM-DD> first day funnel, campaigns and money cover (default: 29 days before --to)
|
|
1350
|
+
--to <YYYY-MM-DD> last day funnel, campaigns and money cover (default: today in the workspace reporting time zone)
|
|
1351
|
+
--grain <grain> how funnel and money group days: day (default), week (starting Monday) or month
|
|
1352
|
+
--channel <channel> only this channel's campaigns: meta, google, affiliate, a utm_source, or unknown;
|
|
1353
|
+
for content track-link, where the link goes: facebook, email, sms or web (default)
|
|
1354
|
+
--campaign <id> the campaign content track-link puts in utm_campaign
|
|
1355
|
+
--body, --html <text | @file> the words, or the full HTML, content create, capture and edit save
|
|
1356
|
+
--file <path> an image, video or audio file, up to 50 MB, content create, capture and edit save; repeat for several
|
|
1357
|
+
--transcript <text | @file> what is said in the first video or audio file content capture saves, so none is paid for
|
|
1358
|
+
--language <code> the spoken language of --transcript, such as en
|
|
1359
|
+
--source <text> where the content content capture saves came from, in words
|
|
1360
|
+
--url <url> the address of the content content capture saves
|
|
1361
|
+
--tool-run <id> a succeeded tool run content create and edit link; repeat for several
|
|
1362
|
+
--run <id> the agent run making the content (content), or the tool run to record (creatives create)
|
|
1363
|
+
--platform, --external-id where content status launched says it was published, and that platform's id for it
|
|
1364
|
+
--notes <text> what changed in the version skill publish adds
|
|
1365
|
+
--harness <list> which agents skill publish marks the skill for: claude, codex, or claude,codex (default)
|
|
1366
|
+
--text <words> words content search finds in a subject, headline, body or video transcript
|
|
1367
|
+
--mode word|meaning how content search matches --text: word (default, the words themselves) or meaning (close in meaning, nearest first; no --sort)
|
|
1368
|
+
--kind, --platform, --origin which content content search returns and content pull copies (ad, email, sms, ...; meta, ghl, ...; discovered or made)
|
|
1369
|
+
--from, --to <YYYY-MM-DD> with content search, only content live on those days
|
|
1370
|
+
--from, --to <n> with content diff, the two versions to compare (default: the one before the current one, and the current one)
|
|
1371
|
+
--sort <column> how content search sorts: recent (default) or a content_scorecard number, such as click_rate or roas
|
|
1372
|
+
--order desc|asc content search's sort direction (default: desc, best or newest first)
|
|
1373
|
+
--limit <n> how many results content search, creatives list and campaigns return
|
|
1374
|
+
--max-seconds <n> stop a runner's run after this many seconds, if that comes before its deadline
|
|
1375
|
+
--workspace <slug> the workspace whose worker worker promote and worker drop change
|
|
1376
|
+
-h, --help show this help
|
|
1377
|
+
|
|
1378
|
+
skill publish, skill list and skill install <name> use the skill library, which takes the operator token in
|
|
1379
|
+
${envPrefix}_OPERATOR_TOKEN instead of an API key, as do worker promote and worker drop. worker promote makes the
|
|
1380
|
+
version a worker is trying the one in use, there and on every worker of the skill on an older version; worker drop
|
|
1381
|
+
stops trying it.
|
|
1382
|
+
|
|
1383
|
+
runner and runner once take the runner token in ${RUNNER_TOKEN_ENV}. ${HARNESS_COMMAND_ENV}, if set, names a program
|
|
1384
|
+
the runner starts instead of Claude Code or Codex.
|
|
1385
|
+
|
|
1386
|
+
Run \`${brand.slug} docs\` for the full guide.
|
|
1387
|
+
`;
|
|
1388
|
+
function parseOptions(argv) {
|
|
1389
|
+
const { values, positionals } = parseArgs({
|
|
1390
|
+
args: argv,
|
|
1391
|
+
allowPositionals: true,
|
|
1392
|
+
options: {
|
|
1393
|
+
format: { type: "string", default: "table" },
|
|
1394
|
+
"api-url": { type: "string" },
|
|
1395
|
+
file: { type: "string", multiple: true },
|
|
1396
|
+
project: { type: "boolean", default: false },
|
|
1397
|
+
input: { type: "string" },
|
|
1398
|
+
"idempotency-key": { type: "string" },
|
|
1399
|
+
wait: { type: "string", default: String(DEFAULT_WAIT_SECONDS) },
|
|
1400
|
+
status: { type: "string" },
|
|
1401
|
+
kind: { type: "string" },
|
|
1402
|
+
limit: { type: "string" },
|
|
1403
|
+
run: { type: "string" },
|
|
1404
|
+
lineage: { type: "string" },
|
|
1405
|
+
sources: { type: "string" },
|
|
1406
|
+
codex: { type: "boolean", default: false },
|
|
1407
|
+
agent: { type: "string" },
|
|
1408
|
+
"retention-days": { type: "string" },
|
|
1409
|
+
"signing-key-stdin": { type: "boolean", default: false },
|
|
1410
|
+
table: { type: "string", multiple: true },
|
|
1411
|
+
summary: { type: "boolean", default: false },
|
|
1412
|
+
search: { type: "string" },
|
|
1413
|
+
out: { type: "string" },
|
|
1414
|
+
open: { type: "boolean", default: false },
|
|
1415
|
+
media: { type: "boolean", default: false },
|
|
1416
|
+
full: { type: "boolean", default: false },
|
|
1417
|
+
force: { type: "boolean", default: false },
|
|
1418
|
+
yes: { type: "boolean", default: false },
|
|
1419
|
+
from: { type: "string" },
|
|
1420
|
+
to: { type: "string" },
|
|
1421
|
+
grain: { type: "string" },
|
|
1422
|
+
channel: { type: "string" },
|
|
1423
|
+
campaign: { type: "string" },
|
|
1424
|
+
name: { type: "string" },
|
|
1425
|
+
subject: { type: "string" },
|
|
1426
|
+
preview: { type: "string" },
|
|
1427
|
+
headline: { type: "string" },
|
|
1428
|
+
body: { type: "string" },
|
|
1429
|
+
html: { type: "string" },
|
|
1430
|
+
fields: { type: "string" },
|
|
1431
|
+
transcript: { type: "string" },
|
|
1432
|
+
language: { type: "string" },
|
|
1433
|
+
source: { type: "string" },
|
|
1434
|
+
url: { type: "string" },
|
|
1435
|
+
"tool-run": { type: "string", multiple: true },
|
|
1436
|
+
note: { type: "string" },
|
|
1437
|
+
notes: { type: "string" },
|
|
1438
|
+
harness: { type: "string" },
|
|
1439
|
+
platform: { type: "string" },
|
|
1440
|
+
"external-id": { type: "string" },
|
|
1441
|
+
origin: { type: "string" },
|
|
1442
|
+
text: { type: "string" },
|
|
1443
|
+
mode: { type: "string" },
|
|
1444
|
+
sort: { type: "string" },
|
|
1445
|
+
order: { type: "string" },
|
|
1446
|
+
goal: { type: "string" },
|
|
1447
|
+
target: { type: "string" },
|
|
1448
|
+
landing: { type: "string" },
|
|
1449
|
+
"landing-url": { type: "string" },
|
|
1450
|
+
role: { type: "string" },
|
|
1451
|
+
position: { type: "string" },
|
|
1452
|
+
plan: { type: "string" },
|
|
1453
|
+
version: { type: "string" },
|
|
1454
|
+
workspace: { type: "string" },
|
|
1455
|
+
"max-seconds": { type: "string" },
|
|
1456
|
+
help: { type: "boolean", short: "h", default: false },
|
|
1457
|
+
},
|
|
1458
|
+
});
|
|
1459
|
+
if (values.format !== "table" && values.format !== "json") {
|
|
1460
|
+
throw new CliError('--format must be "table" or "json".', exitCodes.usage);
|
|
1461
|
+
}
|
|
1462
|
+
const wait = Number(values.wait);
|
|
1463
|
+
if (!Number.isInteger(wait) || wait < 0)
|
|
1464
|
+
throw new CliError("--wait must be a whole number of seconds.", exitCodes.usage);
|
|
1465
|
+
const maxSeconds = values["max-seconds"] === undefined ? undefined : Number(values["max-seconds"]);
|
|
1466
|
+
if (maxSeconds !== undefined && (!Number.isInteger(maxSeconds) || maxSeconds < 1)) {
|
|
1467
|
+
throw new CliError("--max-seconds must be a whole number of seconds, at least 1.", exitCodes.usage);
|
|
1468
|
+
}
|
|
1469
|
+
return {
|
|
1470
|
+
positionals,
|
|
1471
|
+
help: values.help,
|
|
1472
|
+
options: {
|
|
1473
|
+
format: values.format,
|
|
1474
|
+
apiUrl: values["api-url"],
|
|
1475
|
+
file: values.file?.at(-1),
|
|
1476
|
+
files: values.file ?? [],
|
|
1477
|
+
project: values.project,
|
|
1478
|
+
input: values.input,
|
|
1479
|
+
idempotencyKey: values["idempotency-key"],
|
|
1480
|
+
wait,
|
|
1481
|
+
status: values.status,
|
|
1482
|
+
kind: values.kind,
|
|
1483
|
+
limit: values.limit,
|
|
1484
|
+
run: values.run,
|
|
1485
|
+
lineage: values.lineage,
|
|
1486
|
+
sources: values.sources,
|
|
1487
|
+
codex: values.codex,
|
|
1488
|
+
agent: values.agent ?? (process.env[AGENT_ENV] || "meta_ads"),
|
|
1489
|
+
retentionDays: values["retention-days"],
|
|
1490
|
+
signingKeyStdin: values["signing-key-stdin"],
|
|
1491
|
+
table: values.table?.at(-1),
|
|
1492
|
+
tables: values.table ?? [],
|
|
1493
|
+
summary: values.summary,
|
|
1494
|
+
search: values.search,
|
|
1495
|
+
out: values.out,
|
|
1496
|
+
open: values.open,
|
|
1497
|
+
media: values.media,
|
|
1498
|
+
full: values.full,
|
|
1499
|
+
force: values.force,
|
|
1500
|
+
yes: values.yes,
|
|
1501
|
+
from: values.from,
|
|
1502
|
+
to: values.to,
|
|
1503
|
+
grain: values.grain,
|
|
1504
|
+
channel: values.channel,
|
|
1505
|
+
campaign: values.campaign,
|
|
1506
|
+
name: values.name,
|
|
1507
|
+
subject: values.subject,
|
|
1508
|
+
preview: values.preview,
|
|
1509
|
+
headline: values.headline,
|
|
1510
|
+
body: values.body,
|
|
1511
|
+
html: values.html,
|
|
1512
|
+
fields: values.fields,
|
|
1513
|
+
transcript: values.transcript,
|
|
1514
|
+
language: values.language,
|
|
1515
|
+
source: values.source,
|
|
1516
|
+
url: values.url,
|
|
1517
|
+
toolRuns: values["tool-run"] ?? [],
|
|
1518
|
+
note: values.note,
|
|
1519
|
+
notes: values.notes,
|
|
1520
|
+
harness: values.harness,
|
|
1521
|
+
platform: values.platform,
|
|
1522
|
+
externalId: values["external-id"],
|
|
1523
|
+
origin: values.origin,
|
|
1524
|
+
text: values.text,
|
|
1525
|
+
mode: values.mode,
|
|
1526
|
+
sort: values.sort,
|
|
1527
|
+
order: values.order,
|
|
1528
|
+
goal: values.goal,
|
|
1529
|
+
target: values.target,
|
|
1530
|
+
landing: values.landing,
|
|
1531
|
+
landingUrl: values["landing-url"],
|
|
1532
|
+
role: values.role,
|
|
1533
|
+
position: values.position,
|
|
1534
|
+
plan: values.plan,
|
|
1535
|
+
version: values.version,
|
|
1536
|
+
workspace: values.workspace,
|
|
1537
|
+
maxSeconds,
|
|
1538
|
+
},
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
/** Promotes or drops the candidate version of a workspace's worker for a skill, with the operator token. */
|
|
1542
|
+
async function changeCandidate(change, args, options) {
|
|
1543
|
+
if (args.length !== 1 || !options.workspace)
|
|
1544
|
+
throw new CliError(`Usage: worker ${change} <skill> --workspace <slug>`, exitCodes.usage);
|
|
1545
|
+
return api(await operatorConnection(options.apiUrl), `/api/operator/workers/${change}`, { workspace: options.workspace, skill: args[0] });
|
|
1546
|
+
}
|
|
1547
|
+
function findCommand(positionals) {
|
|
1548
|
+
const two = positionals.slice(0, 2).join(" ");
|
|
1549
|
+
if (Object.hasOwn(commands, two))
|
|
1550
|
+
return { command: commands[two], args: positionals.slice(2) };
|
|
1551
|
+
const one = positionals[0] ?? "";
|
|
1552
|
+
return Object.hasOwn(commands, one) ? { command: commands[one], args: positionals.slice(1) } : null;
|
|
1553
|
+
}
|
|
1554
|
+
async function readStdin() {
|
|
1555
|
+
const chunks = [];
|
|
1556
|
+
for await (const chunk of process.stdin)
|
|
1557
|
+
chunks.push(chunk);
|
|
1558
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
1559
|
+
}
|
|
1560
|
+
async function readKeyFromUser() {
|
|
1561
|
+
const fromEnv = process.env[`${envPrefix}_API_KEY`];
|
|
1562
|
+
const key = (fromEnv || (process.stdin.isTTY ? await promptHidden("Paste your API key: ") : await readStdin())).trim();
|
|
1563
|
+
if (!key)
|
|
1564
|
+
throw new CliError(`No API key given. Paste it when asked, pipe it on stdin, or set ${envPrefix}_API_KEY.`, exitCodes.usage);
|
|
1565
|
+
return key;
|
|
1566
|
+
}
|
|
1567
|
+
async function readSigningKey() {
|
|
1568
|
+
const key = (process.stdin.isTTY ? await promptHidden("Paste the signing key: ") : await readStdin()).trim();
|
|
1569
|
+
if (!key)
|
|
1570
|
+
throw new CliError("No signing key given. Paste it when asked or pipe it on stdin.", exitCodes.usage);
|
|
1571
|
+
return key;
|
|
1572
|
+
}
|
|
1573
|
+
function promptHidden(prompt) {
|
|
1574
|
+
const stdin = process.stdin;
|
|
1575
|
+
return new Promise((resolve, reject) => {
|
|
1576
|
+
let value = "";
|
|
1577
|
+
let escape = "none";
|
|
1578
|
+
let settled = false;
|
|
1579
|
+
const settle = (error) => {
|
|
1580
|
+
if (settled)
|
|
1581
|
+
return;
|
|
1582
|
+
settled = true;
|
|
1583
|
+
stdin.off("data", onData);
|
|
1584
|
+
stdin.off("error", settle);
|
|
1585
|
+
stdin.off("end", onEnd);
|
|
1586
|
+
process.off("SIGINT", onSigint);
|
|
1587
|
+
if (stdin.isRaw)
|
|
1588
|
+
stdin.setRawMode(false);
|
|
1589
|
+
stdin.pause();
|
|
1590
|
+
process.stderr.write("\n");
|
|
1591
|
+
if (error)
|
|
1592
|
+
reject(error);
|
|
1593
|
+
else
|
|
1594
|
+
resolve(value);
|
|
1595
|
+
};
|
|
1596
|
+
const onSigint = () => settle(new CliError("Cancelled.", exitCodes.interrupted));
|
|
1597
|
+
const onEnd = () => settle();
|
|
1598
|
+
const onData = (chunk) => {
|
|
1599
|
+
try {
|
|
1600
|
+
for (const char of chunk) {
|
|
1601
|
+
if (escape === "start") {
|
|
1602
|
+
escape = char === "[" || char === "O" ? "sequence" : "none";
|
|
1603
|
+
}
|
|
1604
|
+
else if (escape === "sequence") {
|
|
1605
|
+
if (char >= "@" && char <= "~")
|
|
1606
|
+
escape = "none";
|
|
1607
|
+
}
|
|
1608
|
+
else if (char === "\u001b") {
|
|
1609
|
+
escape = "start";
|
|
1610
|
+
}
|
|
1611
|
+
else if (char === "\r" || char === "\n") {
|
|
1612
|
+
return settle();
|
|
1613
|
+
}
|
|
1614
|
+
else if (char === "\u0003") {
|
|
1615
|
+
return onSigint();
|
|
1616
|
+
}
|
|
1617
|
+
else if (char === "\u007f" || char === "\b") {
|
|
1618
|
+
value = value.slice(0, -1);
|
|
1619
|
+
}
|
|
1620
|
+
else if (char >= " ") {
|
|
1621
|
+
value += char;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
catch (error) {
|
|
1626
|
+
settle(error);
|
|
1627
|
+
}
|
|
1628
|
+
};
|
|
1629
|
+
process.once("SIGINT", onSigint);
|
|
1630
|
+
stdin.on("error", settle);
|
|
1631
|
+
stdin.once("end", onEnd);
|
|
1632
|
+
try {
|
|
1633
|
+
stdin.setRawMode(true);
|
|
1634
|
+
stdin.setEncoding("utf8");
|
|
1635
|
+
stdin.on("data", onData);
|
|
1636
|
+
stdin.resume();
|
|
1637
|
+
}
|
|
1638
|
+
catch (error) {
|
|
1639
|
+
return settle(error);
|
|
1640
|
+
}
|
|
1641
|
+
process.stderr.write(prompt);
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
async function readSql(args, file) {
|
|
1645
|
+
if (args.length > 1) {
|
|
1646
|
+
throw new CliError('Pass the SQL as one quoted argument: warehouse query "SELECT ..."', exitCodes.usage);
|
|
1647
|
+
}
|
|
1648
|
+
let sql;
|
|
1649
|
+
if (file)
|
|
1650
|
+
sql = await readFile(await inRunFolder(file), "utf8");
|
|
1651
|
+
else if (args[0] !== undefined && args[0] !== "-")
|
|
1652
|
+
sql = args[0];
|
|
1653
|
+
else if (args[0] === "-" || !process.stdin.isTTY)
|
|
1654
|
+
sql = await readStdin();
|
|
1655
|
+
else
|
|
1656
|
+
sql = "";
|
|
1657
|
+
if (!sql.trim())
|
|
1658
|
+
throw new CliError('No SQL given. Usage: warehouse query "<sql>" | --file <path> | < query.sql', exitCodes.usage);
|
|
1659
|
+
return sql;
|
|
1660
|
+
}
|
|
1661
|
+
async function readText(file, what) {
|
|
1662
|
+
const text = file ? await readFile(await inRunFolder(file), "utf8") : process.stdin.isTTY ? "" : await readStdin();
|
|
1663
|
+
if (!text.trim())
|
|
1664
|
+
throw new CliError(`No text given for ${what}. Pass --file <path> or pipe it on stdin.`, exitCodes.usage);
|
|
1665
|
+
return text;
|
|
1666
|
+
}
|
|
1667
|
+
/** A JSON flag value, or `@path` to read the JSON from a file. */
|
|
1668
|
+
async function parseJson(value, flag) {
|
|
1669
|
+
const text = value.startsWith("@") ? await readFile(await inRunFolder(value.slice(1)), "utf8") : value;
|
|
1670
|
+
try {
|
|
1671
|
+
return JSON.parse(text);
|
|
1672
|
+
}
|
|
1673
|
+
catch {
|
|
1674
|
+
throw new CliError(`${flag} is not valid JSON: ${excerpt(text)}`, exitCodes.usage);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
/** A flag's text, or `@path` to read the text from a file. */
|
|
1678
|
+
async function textOrFile(value) {
|
|
1679
|
+
return value.startsWith("@") ? readFile(await inRunFolder(value.slice(1)), "utf8") : value;
|
|
1680
|
+
}
|
|
1681
|
+
const FILE_TYPES = {
|
|
1682
|
+
".jpg": "image/jpeg",
|
|
1683
|
+
".jpeg": "image/jpeg",
|
|
1684
|
+
".png": "image/png",
|
|
1685
|
+
".webp": "image/webp",
|
|
1686
|
+
".gif": "image/gif",
|
|
1687
|
+
".mp4": "video/mp4",
|
|
1688
|
+
".mov": "video/quicktime",
|
|
1689
|
+
".webm": "video/webm",
|
|
1690
|
+
".mp3": "audio/mpeg",
|
|
1691
|
+
".m4a": "audio/mp4",
|
|
1692
|
+
".wav": "audio/wav",
|
|
1693
|
+
};
|
|
1694
|
+
// The server takes at most this many bytes of base64 files and HTML in one request.
|
|
1695
|
+
const INLINE_CONTENT_BYTES = 3 * 1024 * 1024;
|
|
1696
|
+
/**
|
|
1697
|
+
* The request body the content create and edit flags describe; flags not given are left out. Files go base64 while
|
|
1698
|
+
* they and the HTML fit in one request; any other file goes to storage first and is named by its upload id.
|
|
1699
|
+
*/
|
|
1700
|
+
async function contentInput(options, connection) {
|
|
1701
|
+
const html = options.html === undefined ? undefined : await textOrFile(options.html);
|
|
1702
|
+
let inline = html === undefined ? 0 : Buffer.byteLength(html);
|
|
1703
|
+
const files = [];
|
|
1704
|
+
for (const path of options.files) {
|
|
1705
|
+
const contentType = FILE_TYPES[extname(path).toLowerCase()];
|
|
1706
|
+
if (!contentType)
|
|
1707
|
+
throw new CliError(`${path}: --file takes ${Object.keys(FILE_TYPES).join(", ")} files.`, exitCodes.usage);
|
|
1708
|
+
const data = await readFile(await inRunFolder(path));
|
|
1709
|
+
if (inline + data.byteLength <= INLINE_CONTENT_BYTES) {
|
|
1710
|
+
inline += data.byteLength;
|
|
1711
|
+
files.push({ base64: data.toString("base64"), contentType });
|
|
1712
|
+
}
|
|
1713
|
+
else {
|
|
1714
|
+
files.push({ uploadId: await uploadContentFile(connection, data, contentType) });
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
const input = {
|
|
1718
|
+
name: options.name,
|
|
1719
|
+
subject: options.subject,
|
|
1720
|
+
previewText: options.preview,
|
|
1721
|
+
headline: options.headline,
|
|
1722
|
+
body: options.body === undefined ? undefined : await textOrFile(options.body),
|
|
1723
|
+
html,
|
|
1724
|
+
fields: options.fields === undefined ? undefined : await parseJson(options.fields, "--fields"),
|
|
1725
|
+
files: files.length > 0 ? files : undefined,
|
|
1726
|
+
toolRunIds: options.toolRuns.length > 0 ? options.toolRuns : undefined,
|
|
1727
|
+
runId: options.run,
|
|
1728
|
+
note: options.note,
|
|
1729
|
+
};
|
|
1730
|
+
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
1731
|
+
}
|
|
1732
|
+
function deploymentLine(d) {
|
|
1733
|
+
const line = `${d.url} v${d.version} ${d.status} (${d.id})`;
|
|
1734
|
+
if (d.pageItemId === null)
|
|
1735
|
+
return line;
|
|
1736
|
+
return `${line}, read as ${d.pageItemId} v${d.currentPageVersion}, edited on the host ${d.editedOnHost} ${d.editedOnHost === 1 ? "time" : "times"} since it went live`;
|
|
1737
|
+
}
|
|
1738
|
+
async function uploadContentFile(connection, data, contentType) {
|
|
1739
|
+
const upload = await api(connection, "/api/v1/content/uploads", { contentType, bytes: data.byteLength });
|
|
1740
|
+
await putSigned(upload.url, data, contentType);
|
|
1741
|
+
return upload.uploadId;
|
|
1742
|
+
}
|
|
1743
|
+
function printContent(content, format) {
|
|
1744
|
+
if (format === "json")
|
|
1745
|
+
return printJson(content);
|
|
1746
|
+
const { item, version } = content;
|
|
1747
|
+
const rows = {
|
|
1748
|
+
sku: item.sku,
|
|
1749
|
+
id: item.id,
|
|
1750
|
+
kind: item.kind,
|
|
1751
|
+
status: item.status,
|
|
1752
|
+
name: item.name,
|
|
1753
|
+
version: content.created === false ? `${version.version} (unchanged)` : version.version,
|
|
1754
|
+
subject: version.subject,
|
|
1755
|
+
preview: version.previewText,
|
|
1756
|
+
headline: version.headline,
|
|
1757
|
+
body: version.body ? excerpt(version.body) : null,
|
|
1758
|
+
html: version.htmlUrl,
|
|
1759
|
+
files: version.media.length ? version.media.map((m) => m.url).join(" ") : null,
|
|
1760
|
+
links: item.links.length
|
|
1761
|
+
? item.links.map((l) => `${l.platform} ${l.objectType} ${l.externalId}${l.removedAt ? ` (no longer on ${l.platform})` : ""}`).join(", ")
|
|
1762
|
+
: null,
|
|
1763
|
+
by: version.capturedBy,
|
|
1764
|
+
note: version.note,
|
|
1765
|
+
publications: content.publications?.length
|
|
1766
|
+
? content.publications.map((p) => [p.platform, p.objectType, p.externalId, p.status].filter(Boolean).join(" ")).join(", ")
|
|
1767
|
+
: null,
|
|
1768
|
+
"live copy of": item.liveCopyOf ? `${item.liveCopyOf.sku ?? item.liveCopyOf.itemId} (deployment ${item.liveCopyOf.deploymentId})` : null,
|
|
1769
|
+
deployments: content.deployments?.length ? content.deployments.map(deploymentLine).join(", ") : null,
|
|
1770
|
+
versions: content.versions && content.versions.length > 1 ? content.versions.length : null,
|
|
1771
|
+
transcript: content.transcripts?.map((t) => (t.text ? excerpt(t.text, 80) : t.status)).join(" | ") || null,
|
|
1772
|
+
...scoreFields(content.scorecards ?? []),
|
|
1773
|
+
};
|
|
1774
|
+
printFields(rows);
|
|
1775
|
+
}
|
|
1776
|
+
function printContentDiff(diff) {
|
|
1777
|
+
const stamp = (sign, v) => `${sign} version ${v.version}, ${v.capturedAt} by ${v.capturedBy}${v.note ? `: ${v.note}` : ""}`;
|
|
1778
|
+
const value = (v) => (typeof v === "string" ? v : JSON.stringify(v));
|
|
1779
|
+
const lines = [`${diff.item.sku ?? diff.item.id} (${diff.item.kind}${diff.item.name ? `, ${diff.item.name}` : ""})`, stamp("---", diff.from), stamp("+++", diff.to)];
|
|
1780
|
+
const section = (title, rows) => {
|
|
1781
|
+
if (rows.length > 0)
|
|
1782
|
+
lines.push("", title, ...rows);
|
|
1783
|
+
};
|
|
1784
|
+
for (const c of diff.changed)
|
|
1785
|
+
section(c.field, [...(c.from === null ? [] : [`- ${c.from}`]), ...(c.to === null ? [] : [`+ ${c.to}`])]);
|
|
1786
|
+
section("body", diff.body.map((l) => (l.op === "skip" ? ` ... ${l.lines} unchanged line${l.lines === 1 ? "" : "s"}` : `${{ same: " ", add: "+", remove: "-" }[l.op]} ${l.text}`)));
|
|
1787
|
+
for (const l of diff.lists)
|
|
1788
|
+
section(l.field, [...l.removed.map((v) => `- ${value(v)}`), ...l.added.map((v) => `+ ${value(v)}`)]);
|
|
1789
|
+
if (diff.html)
|
|
1790
|
+
section("html", [...(diff.html.fromUrl ? [`- ${diff.html.fromUrl}`] : []), ...(diff.html.toUrl ? [`+ ${diff.html.toUrl}`] : [])]);
|
|
1791
|
+
const file = (m) => `${m.contentType} ${m.sha256.slice(0, 12)} ${m.url}`;
|
|
1792
|
+
section("files", [...diff.media.removed.map((m) => `- ${file(m)}`), ...diff.media.added.map((m) => `+ ${file(m)}`)]);
|
|
1793
|
+
if (lines.length === 3)
|
|
1794
|
+
lines.push("", "No changes.");
|
|
1795
|
+
console.log(lines.join("\n"));
|
|
1796
|
+
}
|
|
1797
|
+
const planPath = (id) => `/api/v1/launch-plans/${encodeURIComponent(id)}`;
|
|
1798
|
+
function printPlan(plan, format) {
|
|
1799
|
+
if (format === "json")
|
|
1800
|
+
return printJson({ plan });
|
|
1801
|
+
printFields({
|
|
1802
|
+
id: plan.id,
|
|
1803
|
+
name: plan.name,
|
|
1804
|
+
goal: excerpt(plan.goal),
|
|
1805
|
+
channel: plan.channel,
|
|
1806
|
+
target: plan.target && JSON.stringify(plan.target),
|
|
1807
|
+
landing: plan.landingUrl ?? plan.landingItemId,
|
|
1808
|
+
status: plan.status,
|
|
1809
|
+
next: plan.moves.length ? plan.moves.join(", ") : null,
|
|
1810
|
+
by: plan.createdBy,
|
|
1811
|
+
});
|
|
1812
|
+
console.log("");
|
|
1813
|
+
if (plan.items.length === 0)
|
|
1814
|
+
return console.log("No items yet.");
|
|
1815
|
+
console.log(renderTable(["position", "sku", "kind", "role", "status", "name"], plan.items));
|
|
1816
|
+
}
|
|
1817
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1818
|
+
const dollars = (cents) => `$${(cents / 100).toFixed(2)}`;
|
|
1819
|
+
/** The workspace's worker for the skill named `ref`, else the worker whose id it is: a skill's name may look like a uuid. */
|
|
1820
|
+
async function workerId(connection, ref) {
|
|
1821
|
+
const { workers } = await api(connection, "/api/v1/workers");
|
|
1822
|
+
const found = workers.find((w) => w.skill === ref) ?? workers.find((w) => w.id === ref.toLowerCase());
|
|
1823
|
+
if (!found)
|
|
1824
|
+
throw new CliError(`unknown_worker: no worker with skill or id ${JSON.stringify(ref)} in this workspace`, exitCodes.error);
|
|
1825
|
+
return found.id;
|
|
1826
|
+
}
|
|
1827
|
+
function workerRunRow(r) {
|
|
1828
|
+
const cents = (value) => (value === null ? "" : dollars(value));
|
|
1829
|
+
const seconds = r.startedAt && r.finishedAt ? Math.round((Date.parse(r.finishedAt) - Date.parse(r.startedAt)) / 1000) : null;
|
|
1830
|
+
return {
|
|
1831
|
+
id: r.shadowOf ? `${r.id} (shadow)` : r.id,
|
|
1832
|
+
version: r.version,
|
|
1833
|
+
status: r.reason ? `${r.status} (${r.reason})` : r.status,
|
|
1834
|
+
queued: r.queuedAt,
|
|
1835
|
+
finished: r.finishedAt,
|
|
1836
|
+
duration: seconds === null ? "" : `${seconds}s`,
|
|
1837
|
+
ai: cents(r.aiCents),
|
|
1838
|
+
tools: cents(r.toolCents),
|
|
1839
|
+
compute: cents(r.computeCents),
|
|
1840
|
+
error: r.error && excerpt(r.error),
|
|
1841
|
+
report: r.reportLine && excerpt(r.reportLine),
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
/** The name now, and the name when published after it when the two differ. */
|
|
1845
|
+
function trailName(o) {
|
|
1846
|
+
const now = o.name ?? o.id;
|
|
1847
|
+
return o.nameAtPublish && o.nameAtPublish !== o.name ? `${now} (was ${o.nameAtPublish})` : now;
|
|
1848
|
+
}
|
|
1849
|
+
function printTrail(trail, format) {
|
|
1850
|
+
if (format === "json")
|
|
1851
|
+
return printJson({ trail });
|
|
1852
|
+
const { totals } = trail;
|
|
1853
|
+
printFields({
|
|
1854
|
+
plan: trail.plan && `${trail.plan.name} (${trail.plan.id}, ${trail.plan.status})`,
|
|
1855
|
+
"making cost": dollars(totals.makingCents),
|
|
1856
|
+
...Object.fromEntries(totals.ads.map((a) => [`ads ${a.currency}`, `spend ${a.lifetime.spend ?? 0}, purchases ${a.lifetime.purchases ?? 0}, revenue ${a.lifetime.revenue ?? 0} (30 days: spend ${a.last30Days.spend ?? 0})`])),
|
|
1857
|
+
leads: totals.items.lifetime.leads,
|
|
1858
|
+
sales: totals.items.lifetime.sales,
|
|
1859
|
+
});
|
|
1860
|
+
console.log("");
|
|
1861
|
+
if (trail.items.length === 0)
|
|
1862
|
+
return console.log("No items yet.");
|
|
1863
|
+
const items = trail.items.map((i) => ({
|
|
1864
|
+
sku: i.sku ?? i.id,
|
|
1865
|
+
kind: i.kind,
|
|
1866
|
+
status: i.status,
|
|
1867
|
+
"made with": [...new Set(i.madeWith.map((r) => r.tool))].join(", "),
|
|
1868
|
+
cost: dollars(i.makingCents),
|
|
1869
|
+
leads: i.results.lifetime.leads,
|
|
1870
|
+
sales: i.results.lifetime.sales,
|
|
1871
|
+
}));
|
|
1872
|
+
console.log(renderTable(["sku", "kind", "status", "made with", "cost", "leads", "sales"], items));
|
|
1873
|
+
const lines = trail.items.flatMap((i) => i.publications.map((p) => ({
|
|
1874
|
+
sku: i.sku ?? i.id,
|
|
1875
|
+
where: `${p.platform} ${p.objectType} ${p.externalId ?? ""}`.trim(),
|
|
1876
|
+
status: p.status,
|
|
1877
|
+
campaign: trailName(p.campaign),
|
|
1878
|
+
"ad set": trailName(p.adset),
|
|
1879
|
+
ad: trailName(p.ad),
|
|
1880
|
+
delivery: p.ad.status,
|
|
1881
|
+
spend: p.results.lifetime.spend === null ? null : `${p.results.lifetime.spend} ${p.results.currency}`,
|
|
1882
|
+
purchases: p.results.lifetime.purchases,
|
|
1883
|
+
})));
|
|
1884
|
+
console.log("");
|
|
1885
|
+
if (lines.length === 0)
|
|
1886
|
+
return console.log("Not published anywhere yet.");
|
|
1887
|
+
console.log(renderTable(["sku", "where", "status", "campaign", "ad set", "ad", "delivery", "spend", "purchases"], lines));
|
|
1888
|
+
}
|
|
1889
|
+
/** One scorecard column as a table cell: the number, or each currency's number when the item has money in several. */
|
|
1890
|
+
function scoreCell(scorecards, column) {
|
|
1891
|
+
const scored = scorecards.filter((s) => s.numbers[column] !== undefined);
|
|
1892
|
+
if (scored.length <= 1)
|
|
1893
|
+
return scored[0]?.numbers[column] ?? null;
|
|
1894
|
+
return scored.map((s) => `${s.numbers[column]} ${s.currency ?? ""}`.trimEnd()).join(", ");
|
|
1895
|
+
}
|
|
1896
|
+
/** Every scorecard number as a field, named with its currency when the item has money in several. */
|
|
1897
|
+
function scoreFields(scorecards) {
|
|
1898
|
+
if (scorecards.length === 1)
|
|
1899
|
+
return { currency: scorecards[0].currency, ...scorecards[0].numbers };
|
|
1900
|
+
return Object.fromEntries(scorecards.flatMap((s) => Object.entries(s.numbers).map(([column, value]) => [`${column} (${s.currency ?? "no money"})`, value])));
|
|
1901
|
+
}
|
|
1902
|
+
function printFields(rows) {
|
|
1903
|
+
console.log(renderTable(["field", "value"], Object.entries(rows).filter(([, v]) => v != null).map(([field, value]) => ({ field, value }))));
|
|
1904
|
+
}
|
|
1905
|
+
function printPublication(publication, format) {
|
|
1906
|
+
if (format === "json")
|
|
1907
|
+
return printJson({ publication });
|
|
1908
|
+
printFields({
|
|
1909
|
+
publication: publication.id,
|
|
1910
|
+
item: publication.itemId,
|
|
1911
|
+
version: publication.version,
|
|
1912
|
+
platform: publication.platform,
|
|
1913
|
+
publisher: publication.publisher,
|
|
1914
|
+
account: publication.accountId,
|
|
1915
|
+
object: publication.objectType,
|
|
1916
|
+
"external id": publication.externalId,
|
|
1917
|
+
status: publication.status,
|
|
1918
|
+
error: publication.error,
|
|
1919
|
+
recorded: publication.recordedAt,
|
|
1920
|
+
sent: publication.sentAt,
|
|
1921
|
+
finished: publication.finishedAt,
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
async function readToolInput(options) {
|
|
1925
|
+
let text;
|
|
1926
|
+
if (options.input !== undefined)
|
|
1927
|
+
text = options.input;
|
|
1928
|
+
else if (options.file)
|
|
1929
|
+
text = await readFile(await inRunFolder(options.file), "utf8");
|
|
1930
|
+
else if (!process.stdin.isTTY)
|
|
1931
|
+
text = await readStdin();
|
|
1932
|
+
else
|
|
1933
|
+
text = "";
|
|
1934
|
+
if (!text.trim())
|
|
1935
|
+
return {};
|
|
1936
|
+
try {
|
|
1937
|
+
return JSON.parse(text);
|
|
1938
|
+
}
|
|
1939
|
+
catch {
|
|
1940
|
+
throw new CliError(`The tool input is not valid JSON: ${excerpt(text)}`, exitCodes.usage);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
// The server holds a request open for at most SERVER_WAIT_CAP seconds, so longer waits are several requests.
|
|
1944
|
+
async function waitForRun(connection, run, seconds) {
|
|
1945
|
+
const deadline = Date.now() + seconds * 1000;
|
|
1946
|
+
let current = run;
|
|
1947
|
+
while ((current.status === "pending" || current.status === "running") && Date.now() < deadline) {
|
|
1948
|
+
const wait = Math.max(1, Math.min(SERVER_WAIT_CAP, Math.ceil((deadline - Date.now()) / 1000)));
|
|
1949
|
+
current = (await api(connection, `/api/v1/runs/${current.id}?wait=${wait}`)).run;
|
|
1950
|
+
}
|
|
1951
|
+
return current;
|
|
1952
|
+
}
|
|
1953
|
+
function printRun(run, format) {
|
|
1954
|
+
if (run.status === "failed" || run.status === "lost")
|
|
1955
|
+
process.exitCode = exitCodes.error;
|
|
1956
|
+
if (format === "json")
|
|
1957
|
+
return printJson({ run });
|
|
1958
|
+
const rows = [
|
|
1959
|
+
{ field: "run", value: run.id },
|
|
1960
|
+
{ field: "tool", value: run.tool },
|
|
1961
|
+
{ field: "status", value: run.status },
|
|
1962
|
+
{ field: "estimatedCents", value: run.estimatedCents },
|
|
1963
|
+
{ field: "actualCents", value: run.actualCents },
|
|
1964
|
+
];
|
|
1965
|
+
if (run.error)
|
|
1966
|
+
rows.push({ field: "error", value: run.error });
|
|
1967
|
+
for (const asset of run.result?.assets ?? [])
|
|
1968
|
+
rows.push({ field: "asset", value: asset.url });
|
|
1969
|
+
if (run.result?.data !== undefined)
|
|
1970
|
+
rows.push({ field: "data", value: run.result.data });
|
|
1971
|
+
console.log(renderTable(["field", "value"], rows));
|
|
1972
|
+
if (run.result?.text)
|
|
1973
|
+
console.log(`\n${run.result.text}`);
|
|
1974
|
+
if (run.status === "pending" || run.status === "running") {
|
|
1975
|
+
console.error(`Still ${run.status}. Check again with: ${brand.slug} tools get ${run.id} --wait 60`);
|
|
1976
|
+
}
|
|
1977
|
+
}
|
|
1978
|
+
const HOSTED_UPLOAD_MB = 4.5;
|
|
1979
|
+
const megabytes = (bytes) => Math.round((bytes / (1024 * 1024)) * 10) / 10;
|
|
1980
|
+
/** A file over the hosted app's request limit goes to storage first, and the server imports it from there. */
|
|
1981
|
+
async function uploadCsv(connection, name, file) {
|
|
1982
|
+
const table = `/api/v1/imports/csv?table=${encodeURIComponent(name)}`;
|
|
1983
|
+
if (file.byteLength > HOSTED_UPLOAD_MB * 1024 * 1024) {
|
|
1984
|
+
const upload = await api(connection, "/api/v1/imports/csv/uploads", {}).catch((error) => {
|
|
1985
|
+
// A server without storage takes the file directly, which works for anything its host lets through.
|
|
1986
|
+
if (error instanceof ApiError && error.code === "storage_not_configured")
|
|
1987
|
+
return null;
|
|
1988
|
+
throw error;
|
|
1989
|
+
});
|
|
1990
|
+
if (upload) {
|
|
1991
|
+
await putSigned(upload.uploadUrl, file, "text/csv");
|
|
1992
|
+
return api(connection, `${table}&upload=${encodeURIComponent(upload.uploadId)}`, undefined, "POST");
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
try {
|
|
1996
|
+
return await apiUpload(connection, table, file, "text/csv");
|
|
1997
|
+
}
|
|
1998
|
+
catch (error) {
|
|
1999
|
+
// The importer's own 413 is too_large; any other 413 is the hosting platform refusing the body before the app sees it.
|
|
2000
|
+
if (error instanceof ApiError && error.status === 413 && error.code !== "too_large") {
|
|
2001
|
+
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);
|
|
2002
|
+
}
|
|
2003
|
+
throw error;
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
/** Refuses a name the server would refuse, before anything is sent. */
|
|
2007
|
+
function importName(name) {
|
|
2008
|
+
if (!IMPORT_NAME_PATTERN.test(name)) {
|
|
2009
|
+
throw new CliError(`"${excerpt(name)}" can't be a table name. Use lowercase letters, digits and underscores, start with a letter, and keep it to 41 characters or fewer.`, exitCodes.usage);
|
|
2010
|
+
}
|
|
2011
|
+
return name;
|
|
2012
|
+
}
|
|
2013
|
+
async function readImportFile(path) {
|
|
2014
|
+
await inRunFolder(path);
|
|
2015
|
+
const info = await stat(path).catch((error) => {
|
|
2016
|
+
if (error.code === "ENOENT")
|
|
2017
|
+
throw new CliError(`No file at ${path}.`, exitCodes.usage);
|
|
2018
|
+
throw error;
|
|
2019
|
+
});
|
|
2020
|
+
if (!info.isFile())
|
|
2021
|
+
throw new CliError(`${path} is not a file.`, exitCodes.usage);
|
|
2022
|
+
if (info.size > IMPORT_LIMITS.bytes) {
|
|
2023
|
+
throw new CliError(`${path} is ${megabytes(info.size)} MB. An import takes at most ${IMPORT_LIMITS.bytes / (1024 * 1024)} MB; split the file and import each part under its own name.`, exitCodes.error);
|
|
2024
|
+
}
|
|
2025
|
+
return readFile(path);
|
|
2026
|
+
}
|
|
2027
|
+
const ASSET_ROUTE = "/api/v1/assets/";
|
|
2028
|
+
/** What `creatives download` fetches, from a SKU, an asset path, or an asset link as `creatives show` prints it. */
|
|
2029
|
+
async function downloadTarget(arg, connection) {
|
|
2030
|
+
const routeAt = arg.indexOf(ASSET_ROUTE);
|
|
2031
|
+
if (routeAt !== -1 || arg.includes("/")) {
|
|
2032
|
+
const assetPath = routeAt === -1 ? arg : decodeLinkPath(arg.slice(routeAt + ASSET_ROUTE.length).replace(/[?#].*$/, ""));
|
|
2033
|
+
return { label: assetPath, assetPath, fileName: basename(assetPath) };
|
|
2034
|
+
}
|
|
2035
|
+
const { creative } = await api(connection, `/api/v1/creatives/${encodeURIComponent(arg)}`);
|
|
2036
|
+
if (!creative.assetPath) {
|
|
2037
|
+
const copy = creative.kind === "copy" ? ` It is copy; its text is in \`${brand.slug} creatives show ${creative.sku}\`.` : "";
|
|
2038
|
+
throw new CliError(`${creative.sku} has no file to download.${copy}`, exitCodes.error);
|
|
2039
|
+
}
|
|
2040
|
+
return { label: creative.sku, assetPath: creative.assetPath, fileName: `${creative.sku}${extname(creative.assetPath)}` };
|
|
2041
|
+
}
|
|
2042
|
+
function decodeLinkPath(encoded) {
|
|
2043
|
+
try {
|
|
2044
|
+
return encoded.split("/").map(decodeURIComponent).join("/");
|
|
2045
|
+
}
|
|
2046
|
+
catch {
|
|
2047
|
+
throw new CliError(`"${excerpt(encoded)}" is not a valid asset link.`, exitCodes.usage);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
/** `--out` names the file, or an existing folder to save into under the default name. */
|
|
2051
|
+
async function savePath(out, fileName) {
|
|
2052
|
+
if (out === undefined)
|
|
2053
|
+
return inRunFolder(fileName);
|
|
2054
|
+
const info = await stat(await inRunFolder(out)).catch((error) => {
|
|
2055
|
+
if (error.code === "ENOENT")
|
|
2056
|
+
return null;
|
|
2057
|
+
throw error;
|
|
2058
|
+
});
|
|
2059
|
+
return inRunFolder(info?.isDirectory() ? join(out, fileName) : out);
|
|
2060
|
+
}
|
|
2061
|
+
function extensionFor(contentType) {
|
|
2062
|
+
const subtype = contentType?.split(";")[0].split("/")[1]?.trim();
|
|
2063
|
+
return subtype && /^[a-z0-9]+$/.test(subtype) ? subtype : "bin";
|
|
2064
|
+
}
|
|
2065
|
+
function fileSize(bytes) {
|
|
2066
|
+
if (bytes < 1024)
|
|
2067
|
+
return `${bytes} ${bytes === 1 ? "byte" : "bytes"}`;
|
|
2068
|
+
return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${megabytes(bytes)} MB`;
|
|
2069
|
+
}
|
|
2070
|
+
function openFile(path) {
|
|
2071
|
+
const [command, args] = process.platform === "darwin" ? ["open", [path]] : process.platform === "win32" ? ["explorer.exe", [path]] : ["xdg-open", [path]];
|
|
2072
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
2073
|
+
child.on("error", (error) => console.error(`Saved, but could not open it: ${error.message}`));
|
|
2074
|
+
child.unref();
|
|
2075
|
+
}
|
|
2076
|
+
const INTAKE_PATH = "/api/v1/intake-endpoints";
|
|
2077
|
+
const intakePath = (id) => `${INTAKE_PATH}/${encodeURIComponent(id)}`;
|
|
2078
|
+
function parseRetentionDays(text) {
|
|
2079
|
+
const days = Number(text);
|
|
2080
|
+
if (!/^\d+$/.test(text) || days < 1)
|
|
2081
|
+
throw new CliError("Retention must be a whole number of days, at least 1.", exitCodes.usage);
|
|
2082
|
+
return days;
|
|
2083
|
+
}
|
|
2084
|
+
function printIntakeToken({ endpoint, address, token, link }, format) {
|
|
2085
|
+
if (format === "json")
|
|
2086
|
+
printJson({ endpoint, address, token, link });
|
|
2087
|
+
else {
|
|
2088
|
+
if (link)
|
|
2089
|
+
console.log(`Link: ${link}`);
|
|
2090
|
+
console.log(`Address: ${address}`);
|
|
2091
|
+
console.log(`Header: Authorization: Bearer ${token}`);
|
|
2092
|
+
console.log(`Token: ${token}`);
|
|
2093
|
+
}
|
|
2094
|
+
console.error(`The token for ${endpoint.label} is shown only now. Rotate it if it is lost.`);
|
|
2095
|
+
}
|
|
2096
|
+
async function patchIntake(id, body, options, done) {
|
|
2097
|
+
const { endpoint } = await api(await resolveConnection(options.apiUrl), intakePath(id), body, "PATCH");
|
|
2098
|
+
if (options.format === "json")
|
|
2099
|
+
return printJson({ endpoint });
|
|
2100
|
+
console.log(done(endpoint));
|
|
2101
|
+
}
|
|
2102
|
+
function excerpt(text, max = 60) {
|
|
2103
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
2104
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
2105
|
+
}
|
|
2106
|
+
function keepNumberDigits(_key, value, context) {
|
|
2107
|
+
return typeof value === "number" && context?.source !== undefined ? context.source : value;
|
|
2108
|
+
}
|
|
2109
|
+
function printJson(value) {
|
|
2110
|
+
console.log(JSON.stringify(value, null, 2));
|
|
2111
|
+
}
|
|
2112
|
+
function cell(value) {
|
|
2113
|
+
if (value === null || value === undefined)
|
|
2114
|
+
return "";
|
|
2115
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
2116
|
+
}
|
|
2117
|
+
function renderTable(columns, rows) {
|
|
2118
|
+
const cells = rows.map((row) => columns.map((c) => cell(row[c])));
|
|
2119
|
+
const widths = columns.map((c, i) => Math.max(c.length, ...cells.map((r) => r[i].length)));
|
|
2120
|
+
const line = (values) => values.map((v, i) => v.padEnd(widths[i])).join(" | ").trimEnd();
|
|
2121
|
+
return [line(columns), widths.map((w) => "-".repeat(w)).join("-+-"), ...cells.map(line)].join("\n");
|
|
2122
|
+
}
|
|
2123
|
+
async function main(argv) {
|
|
2124
|
+
const { positionals, options, help: wantsHelp } = parseOptions(argv);
|
|
2125
|
+
if (wantsHelp || positionals.length === 0) {
|
|
2126
|
+
process.stdout.write(help);
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
const found = findCommand(positionals);
|
|
2130
|
+
if (!found)
|
|
2131
|
+
throw new CliError(`Unknown command. Run \`${brand.slug} --help\`.`, exitCodes.usage);
|
|
2132
|
+
await found.command.run(found.args, options);
|
|
2133
|
+
}
|
|
2134
|
+
try {
|
|
2135
|
+
await main(process.argv.slice(2));
|
|
2136
|
+
}
|
|
2137
|
+
catch (error) {
|
|
2138
|
+
if (error instanceof CliError) {
|
|
2139
|
+
console.error(`error: ${error.message}`);
|
|
2140
|
+
if (error instanceof ApiError && error.hint)
|
|
2141
|
+
console.error(`Hint: ${error.hint}`);
|
|
2142
|
+
process.exitCode = error.exitCode;
|
|
2143
|
+
}
|
|
2144
|
+
else if (error.code?.startsWith("ERR_PARSE_ARGS")) {
|
|
2145
|
+
console.error(`error: ${error.message}`);
|
|
2146
|
+
process.exitCode = exitCodes.usage;
|
|
2147
|
+
}
|
|
2148
|
+
else {
|
|
2149
|
+
throw error;
|
|
2150
|
+
}
|
|
2151
|
+
}
|