@wrangled/cli 0.3.44 → 0.3.46

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  import { mkdir, readFile, rm } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
4
4
  import { brand, envPrefix } from "./brand.js";
5
- import { writeFileAtomic } from "./files.js";
5
+ import { RUN_ROOT_ENV, writeFileAtomic } from "./files.js";
6
6
  export const exitCodes = { ok: 0, error: 1, usage: 2, auth: 3, interrupted: 130 };
7
7
  export class CliError extends Error {
8
8
  exitCode;
@@ -30,8 +30,17 @@ export const IMPORT_NAME_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
30
30
  export function credentialsPath(configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")) {
31
31
  return join(configHome, brand.slug, "credentials.json");
32
32
  }
33
- export async function readCredentials() {
34
- const path = credentialsPath();
33
+ const CLIENT_NAME = /^[a-z0-9][a-z0-9-]*$/;
34
+ /** The file that names a folder's client; it holds only the name, never a key. */
35
+ export const CLIENT_FILE = `.${brand.slug}-client`;
36
+ export function parseClientName(name) {
37
+ if (!CLIENT_NAME.test(name)) {
38
+ throw new CliError(`'${name}' is not a client name: use lowercase letters, digits and dashes, starting with a letter or digit.`, exitCodes.usage);
39
+ }
40
+ return name;
41
+ }
42
+ export async function readSavedLogins(configHome) {
43
+ const path = credentialsPath(configHome);
35
44
  let text;
36
45
  try {
37
46
  text = await readFile(path, "utf8");
@@ -41,38 +50,146 @@ export async function readCredentials() {
41
50
  return null;
42
51
  throw error;
43
52
  }
44
- const credentials = parseCredentials(text);
45
- if (!credentials)
53
+ const saved = parseSavedLogins(text);
54
+ if (!saved)
46
55
  throw new UnreadableCredentialsError(path);
47
- return credentials;
56
+ return saved;
48
57
  }
49
- function parseCredentials(text) {
58
+ function parseSavedLogins(text) {
59
+ let value;
50
60
  try {
51
- const value = JSON.parse(text);
52
- if (typeof value?.apiKey !== "string" || typeof value.apiUrl !== "string")
53
- return null;
54
- return { apiKey: value.apiKey, apiUrl: value.apiUrl };
61
+ value = JSON.parse(text);
55
62
  }
56
63
  catch {
57
64
  return null;
58
65
  }
66
+ if (typeof value !== "object" || value === null)
67
+ return null;
68
+ const { apiKey, apiUrl, clients } = value;
69
+ const top = parseLogin({ apiKey, apiUrl });
70
+ if (top === null && (apiKey !== undefined || apiUrl !== undefined))
71
+ return null;
72
+ // No prototype, so a client named like an Object method (`constructor`) finds nothing rather than a function.
73
+ const named = Object.create(null);
74
+ if (clients !== undefined) {
75
+ if (typeof clients !== "object" || clients === null || Array.isArray(clients))
76
+ return null;
77
+ for (const [name, entry] of Object.entries(clients)) {
78
+ const login = parseLogin(entry);
79
+ if (!CLIENT_NAME.test(name) || login === null)
80
+ return null;
81
+ named[name] = login;
82
+ }
83
+ }
84
+ if (top === null && Object.keys(named).length === 0)
85
+ return null;
86
+ return { default: top, clients: named };
59
87
  }
60
- export async function writeCredentials(credentials, configHome) {
61
- const path = credentialsPath(configHome);
88
+ function parseLogin(value) {
89
+ const { apiKey, apiUrl } = (value ?? {});
90
+ return typeof apiKey === "string" && typeof apiUrl === "string" ? { apiKey, apiUrl } : null;
91
+ }
92
+ /** Saves `credentials` as the default login, or as the named client's, keeping every other saved login. */
93
+ export async function writeCredentials(credentials, options = {}) {
94
+ const path = credentialsPath(options.configHome);
62
95
  await mkdir(dirname(path), { recursive: true, mode: 0o700 });
63
- await writeFileAtomic(path, `${JSON.stringify(credentials, null, 2)}\n`, 0o600);
96
+ const saved = (await readSavedLogins(options.configHome).catch(ignoreUnreadable)) ?? { default: null, clients: {} };
97
+ const next = options.client === undefined ? { ...saved, default: credentials } : { ...saved, clients: { ...saved.clients, [options.client]: credentials } };
98
+ await writeSavedLogins(path, next);
64
99
  return path;
65
100
  }
66
- export async function removeCredentials() {
67
- await rm(credentialsPath(), { force: true });
101
+ /** Removes the default login, or the named client's, keeping the rest; the file goes once nothing is left in it. */
102
+ export async function removeCredentials(client) {
103
+ const path = credentialsPath();
104
+ const saved = await readSavedLogins().catch(ignoreUnreadable);
105
+ const clients = { ...saved?.clients };
106
+ if (client !== undefined)
107
+ delete clients[client];
108
+ const next = { default: client === undefined ? null : (saved?.default ?? null), clients };
109
+ if (next.default === null && Object.keys(clients).length === 0)
110
+ return rm(path, { force: true });
111
+ await writeSavedLogins(path, next);
112
+ }
113
+ async function writeSavedLogins(path, saved) {
114
+ const file = { ...saved.default, ...(Object.keys(saved.clients).length > 0 && { clients: saved.clients }) };
115
+ await writeFileAtomic(path, `${JSON.stringify(file, null, 2)}\n`, 0o600);
116
+ }
117
+ /** An unreadable file is replaced by the next login, as nothing in it can be used. */
118
+ function ignoreUnreadable(error) {
119
+ if (error instanceof UnreadableCredentialsError)
120
+ return null;
121
+ throw error;
122
+ }
123
+ /** The API key env var beats `--client`, which beats the nearest client file above `cwd`, which beats the default login. */
124
+ export async function chooseClient(clientFlag, cwd = process.cwd()) {
125
+ const flagged = clientFlag === undefined ? undefined : parseClientName(clientFlag);
126
+ if (process.env[`${envPrefix}_API_KEY`])
127
+ return { source: "env" };
128
+ if (flagged !== undefined)
129
+ return { source: "flag", client: flagged };
130
+ // A scheduled hand's run holds only its own default key, and must not be steered by files outside its folder.
131
+ if (process.env[RUN_ROOT_ENV])
132
+ return { source: "default" };
133
+ const found = await findClientFile(cwd);
134
+ return found ? { source: "folder", ...found } : { source: "default" };
135
+ }
136
+ async function findClientFile(from) {
137
+ for (let dir = resolve(from);; dir = dirname(dir)) {
138
+ const path = join(dir, CLIENT_FILE);
139
+ const text = await readFile(path, "utf8").catch((error) => {
140
+ if (error.code === "ENOENT" || error.code === "ENOTDIR")
141
+ return null;
142
+ throw new CliError(`Could not read the client file at ${path} (${error.code ?? error.message}), so the client to use is unknown.`, exitCodes.usage);
143
+ });
144
+ if (text !== null)
145
+ return { client: parseClientFile(text, path), path };
146
+ if (dirname(dir) === dir)
147
+ return null;
148
+ }
68
149
  }
69
- export async function resolveConnection(apiUrlFlag) {
70
- const stored = await readCredentials();
71
- const apiKey = process.env[`${envPrefix}_API_KEY`] || stored?.apiKey;
72
- if (!apiKey) {
73
- throw new CliError(`Not logged in. Run \`${brand.slug} login\` or set ${envPrefix}_API_KEY.`, exitCodes.auth);
150
+ function parseClientFile(text, path) {
151
+ const malformed = new CliError(`The client file at ${path} is malformed. It should hold {"client": "<name>"}, a lowercase name with letters, digits and dashes.`, exitCodes.usage);
152
+ let value;
153
+ try {
154
+ value = JSON.parse(text);
74
155
  }
75
- return { apiUrl: resolveApiUrl(apiUrlFlag, stored), apiKey };
156
+ catch {
157
+ throw malformed;
158
+ }
159
+ const client = value?.client;
160
+ if (typeof client !== "string" || !CLIENT_NAME.test(client))
161
+ throw malformed;
162
+ return client;
163
+ }
164
+ export async function resolveLogin(options) {
165
+ const choice = await chooseClient(options.client);
166
+ const saved = await readSavedLogins();
167
+ if (choice.source === "env") {
168
+ return { choice, connection: { apiUrl: resolveApiUrl(options.apiUrl, saved?.default ?? null), apiKey: process.env[`${envPrefix}_API_KEY`] } };
169
+ }
170
+ if (choice.source === "default") {
171
+ if (!saved?.default)
172
+ throw new CliError(`Not logged in. Run \`${brand.slug} login\` or set ${envPrefix}_API_KEY.`, exitCodes.auth);
173
+ return { choice, connection: { apiUrl: resolveApiUrl(options.apiUrl, saved.default), apiKey: saved.default.apiKey } };
174
+ }
175
+ const login = saved?.clients[choice.client];
176
+ const relogin = `\`${brand.slug} login --client ${choice.client}\``;
177
+ if (!login) {
178
+ const why = choice.source === "folder" ? `, named by ${choice.path}` : "";
179
+ throw new CliError(`No saved key for client ${choice.client}${why}. Run ${relogin}.`, exitCodes.auth);
180
+ }
181
+ const apiUrl = resolveApiUrl(options.apiUrl, login);
182
+ return { choice, connection: { apiUrl, apiKey: login.apiKey, rejected: `The API key for client ${choice.client} was rejected by ${apiUrl}. Run ${relogin} with a valid key.` } };
183
+ }
184
+ export async function resolveConnection(options) {
185
+ return (await resolveLogin(options)).connection;
186
+ }
187
+ /** The API URL the chosen login points at, for connections that carry another key; a missing or unreadable login is no error. */
188
+ export async function chosenApiUrl(options) {
189
+ const choice = await chooseClient(options.client);
190
+ const saved = await readSavedLogins().catch(ignoreUnreadable);
191
+ const login = choice.source === "flag" || choice.source === "folder" ? saved?.clients[choice.client] : saved?.default;
192
+ return resolveApiUrl(options.apiUrl, login ?? null);
76
193
  }
77
194
  export function resolveApiUrl(apiUrlFlag, stored) {
78
195
  return (apiUrlFlag || process.env[`${envPrefix}_API_URL`] || stored?.apiUrl || brand.apiUrl).replace(/\/+$/, "");
package/dist/guide.js CHANGED
@@ -23,8 +23,8 @@ export const guide = `# ${brand.name} CLI
23
23
 
24
24
  ## Setup
25
25
 
26
- 1. Check the login: \`${cli} whoami\`. It prints the workspace name, slug, and id, and whether the key has library access.
27
- 2. If it exits with code 3, you are not logged in. Ask the user to run \`${cli} login\` in their own terminal. It prompts for the key without showing it. Never ask for the key in chat and never put it on a command line, where it lands in shell history and logs. In CI, the key comes from the \`${envPrefix}_API_KEY\` environment variable.
26
+ 1. Check the login: \`${cli} whoami\`. It prints the workspace name, slug, and id, whether the key has library access, and which client's key is in use and why.
27
+ 2. If it exits with code 3, you are not logged in. Ask the user to run the \`${cli} login\` command the error names (\`${cli} login --client <name>\` for a named client) in their own terminal. It prompts for the key without showing it. Never ask for the key in chat and never put it on a command line, where it lands in shell history and logs. In CI, the key comes from the \`${envPrefix}_API_KEY\` environment variable.
28
28
 
29
29
  ## Start with the data guide
30
30
 
@@ -140,6 +140,7 @@ A webhook address lets a tool such as a form or webinar platform push events int
140
140
  3. A run that is still going prints its id. Check it with \`${cli} tools get <run-id>\`. Image results list an \`asset\` link. Save the file with \`${cli} creatives download <asset-link>\`, or fetch the link with your API key as a bearer token; it redirects to a copy that works for 10 minutes.
141
141
  4. Record a succeeded run in the ledger with its lineage: \`${cli} creatives create --run <run-id> --lineage '{"persona": "...", "painPoint": "...", "desiredOutcome": "...", "angle": "...", "sourceAd": {"metaAdId": "..."}}'\`. Each output gets a SKU such as \`DLC-000123\`. The SKU goes in the Meta ad name. Lineage is free-text notes; give the ad's marketing tags with \`--tags '<json>'\` or \`--tags @tags.json\`, the same fields as ad tags (see Ad tags), such as \`{"offer_key": "course", "landing_funnel_key": "webinar_to_course", "landing_step_key": "registration_page", "persona": "near_retirement", "angle": "story", "format": "image"}\`. Every output of the run gets them. Values come from \`${cli} grammar\`; persona and concept from this client's lists, and offer, funnel and step from its confirmed map (\`${cli} funnels show\`), where the step must be in that funnel and \`reply\` or \`no_link\` take no step. A value off them is refused with the ones to use. Recording the same run again returns its creatives as they are, tags included; \`${cli} creatives tag <sku> --file tags.json\` is how to change a creative's tags. Your tags read proposed until an Owner or Admin confirms them on the creative's page in Content; after that only a person changes them.
142
142
  5. \`${cli} creatives list\` and \`${cli} creatives show <sku>\` read the ledger. \`${cli} creatives download <sku>\` saves a creative's image or video as \`<sku>.<ext>\` in the current folder; \`--out <path>\` names the file or a folder, and \`--open\` opens it. It also takes an asset path or link, such as a \`meta_ad_creatives\` \`image_path\`. \`--format json\` prints \`{ path, bytes, contentType }\`. Copy has no file; its text is in \`${cli} creatives show\`. For performance, query the \`creatives\` table or the \`creative_performance_daily\` view with \`${cli} warehouse query\`.
143
+ 6. \`${cli} media list\` lists every image and video the tools made, newest first, whether or not it became a creative: its media id (\`<run id>:<index>\`), tool, prompt, cost and SKU, if any. Narrow it with \`--sku <sku>\`, \`--run <run-id>\` (one batch), \`--tool kie:nano-banana\`, \`--kind image|video\`, \`--since\` and \`--until\` (a day such as \`2026-10-08\`, which \`--until\` counts through its end in UTC, or an ISO time), and \`--limit <n>\` (default 50, at most 200). When more remain it prints the \`--cursor <media-id>\` that shows the next page. A run's cost is split evenly across its outputs and marked as an estimate when the provider did not report a charge. \`${cli} media get <media-id>\` shows the full input that made one, to reuse a good prompt; \`${cli} media download <media-id>\` saves its file, named by its SKU when it has one (\`${cli} creatives download\` takes a media id too). \`--format json\` prints \`{ media, nextCursor }\`.
143
144
 
144
145
  ## Landing pages
145
146
 
@@ -291,9 +292,9 @@ A scheduled hand's run acts as its skill's agent, the way the Facebook ads agent
291
292
  ## Commands
292
293
 
293
294
  \`\`\`
294
- ${cli} login validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it
295
- ${cli} logout delete saved credentials
296
- ${cli} whoami show the current workspace and library access
295
+ ${cli} login [--client <name>] validate a key (prompted, piped, or ${envPrefix}_API_KEY) and save it, as the default or under a client's name
296
+ ${cli} logout [--client <name>] delete the default saved login, or one client's
297
+ ${cli} whoami show the current workspace, library access, and which client's key is in use and why
297
298
  ${cli} knowledge search "<question>" [--limit <n>] [--kind <kind>] [--hook-kind <v> ...] short, cited snippets from the know-how libraries (keys with library access)
298
299
  ${cli} knowledge tag <entry-id> --hook-kind <v> --confidence <c> tag a library entry with the marketing grammar (or --file tags.json)
299
300
  ${cli} guide what this workspace's data holds, how fresh it is, and what is missing: read it first
@@ -326,9 +327,12 @@ ${cli} tools run <tool> --input '<json>' [--idempotency-key <key>] [--wait <seco
326
327
  ${cli} tools get <run-id> show a tool run, waiting for it to finish (--wait)
327
328
  ${cli} creatives list list creatives (--status, --kind, --limit)
328
329
  ${cli} creatives show <sku> show one creative with its spec and lineage
329
- ${cli} creatives download <sku | asset-path | asset-link> [--out <path>] [--open] save a creative's image or video
330
+ ${cli} creatives download <sku | media-id | asset-path | asset-link> [--out <path>] [--open] save a creative's image or video
330
331
  ${cli} creatives create --run <run-id> [--lineage '<json>' | @file.json] [--tags '<json>' | @file.json] record a run's output with a new SKU
331
332
  ${cli} creatives tag <sku> --file <tags.json> replace a creative's marketing tags, proposed until a person confirms them
333
+ ${cli} media list list every made image and video (--sku, --run, --tool, --kind, --since, --until, --limit, --cursor)
334
+ ${cli} media get <media-id> show the tool, model, full input and cost that made one
335
+ ${cli} media download <media-id> [--out <path>] [--open] save a made image or video
332
336
  ${cli} content create --kind <kind> [--body ...] [--file <path>] [--note <why>] [--plan <plan-id>] save made content with a new SKU
333
337
  ${cli} content search [--text <words>] [--mode word|meaning] [--kind <kind>] [--platform <p>] [--origin <o>] [--from <date>] [--to <date>] [--sort <column>] [--order asc|desc] [--limit <n>] find content with its numbers
334
338
  ${cli} content pull <dir> [--kind <kind>] [--platform <p>] [--origin <o>] [--media] [--full] copy the library to a folder of Markdown files; rerun for changes