@thenewblack/cli 0.1.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/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @thenewblack/cli — `tnb`
2
+
3
+ [The New Black AI](https://thenewblack.ai) from the command line: every fashion workflow of the studio (design, product-to-model, virtual try-on, fabric, sketch, video, HD), your creations, tech packs, publishing to Shopify and social accounts, and the account's AI agents. One command each, JSON out. Made for AI coding agents (Claude Code, Codex, Cursor…), scripts, crons and CI.
4
+
5
+ ```sh
6
+ npx @thenewblack/cli --help # nothing to install
7
+ npm install -g @thenewblack/cli # or keep it: `tnb`
8
+ ```
9
+
10
+ Node 18.17 or newer. No dependencies.
11
+
12
+ ## The key
13
+
14
+ ```sh
15
+ tnb login # opens your profile's API tab, asks for the key, keeps it (config dir, mode 600)
16
+ export TNB_API_KEY=tnb_live_… # or: the environment wins — for a cron, a CI job, a container
17
+ tnb whoami
18
+ ```
19
+
20
+ Keys are created in your profile, API tab (up to three, each with scopes: `read`, `generate`, `publish`, `agents`). Without a key, `tnb workflows` and every `--help` still work.
21
+
22
+ ## Generate
23
+
24
+ ```sh
25
+ tnb workflows # every workflow, with prices — read live from the catalogue
26
+ tnb generate virtual_try_on --help # the flags of one workflow, read live
27
+ tnb generate virtual_try_on \
28
+ --product_images dress.jpg belt.jpg --model_image model.jpg \
29
+ --seg_camera slightly_above --ratio 4:5 --tier pro \
30
+ --wait --out ./renders/
31
+ # → {"generation_id":"…","status":"succeeded","workflow":"virtual_try_on","url":"https://…","type":"image","file":"renders/dress-virtual_try_on.webp"}
32
+ ```
33
+
34
+ - Pictures are **local files or https urls**. A local file is uploaded once and remembered (path, size, date), so tomorrow's run re-uploads nothing.
35
+ - `--wait` polls until the generation settles; `--out dir/` saves the result with a readable name (`<first input>-<workflow>.<ext>`), `--out file.webp` saves it there. Without `--wait`, the answer is the id and `tnb status <id> --wait` picks it up later.
36
+ - Results live 48 hours on our url. Save them.
37
+ - Credits are debited at submission and refunded on failure; `402 insufficient_credits` means the account needs topping up.
38
+ - Text-only workflows take `--prompt`; guided ones take the catalogue's `--<field>` flags; video ones take `--duration` and, for some, `--end-image`.
39
+
40
+ A folder, one line each:
41
+
42
+ ```sh
43
+ for f in ./products/*.jpg; do tnb generate product_to_model --product_images "$f" --prompt "studio light, neutral background" --wait --out ./renders/; done
44
+ ```
45
+
46
+ ## Everything else
47
+
48
+ ```
49
+ tnb credits · account · ledger · brand-dna [read <id>]
50
+ tnb media [--project id] · elements · projects · upload <files…>
51
+ tnb techpacks · techpack <id> · techpack pdf <id> · techpack rename <id> --name …
52
+ tnb techpack add-section <id> --page <page_id> --kind bom [--at 0]
53
+ tnb techpack write-section <id> <section_id> --data '<json>' | --data @file.json
54
+ tnb techpack from-photos front.jpg [--back back.jpg] [--sketch front] [--size-range XS-XL] [--unit cm]
55
+ tnb moodboards · moodboard pdf <id>
56
+ tnb shopify products · shopify publish --product <id> --media <media_id>
57
+ tnb publish accounts · publish --account <connection_id> --placement <placement> --media <media_id> [--caption …] · post <id>
58
+ tnb agents · agent send <id> "message" [--media …] · agent thread <id> [--since …] · agent stop <id>
59
+ tnb agent schedules <id> · agent schedule <id> "request" --kind single --date 2026-10-01 --time 09:30 · agent unschedule <id> <schedule_id>
60
+ tnb files [--query …] · file <id|path> [--page 2] · file describe <id> --description "…"
61
+ ```
62
+
63
+ `tnb <command> --help` prints the flags; `--pretty` indents the JSON. Exit codes: `0` done, `1` the platform or the network refused (the JSON error is on stderr, with the platform's own `code`), `2` the command line was wrong.
64
+
65
+ ## For an AI agent
66
+
67
+ Install it, run `tnb --help`, then `tnb generate <key> --help` for the workflow you need — the help is the live contract. Read the JSON, keep the ids, save the files. The full API reference is at https://thenewblack.ai/clothing_fashion_api_integrations and the Claude skill at https://github.com/newblackai/claude-skill.
68
+
69
+ ## Configuration
70
+
71
+ | Variable | Meaning |
72
+ |---|---|
73
+ | `TNB_API_KEY` | The key; wins over the one kept by `tnb login`. |
74
+ | `TNB_API_URL` | The API base (default `https://thenewblack.ai/api/v1`). |
75
+ | `TNB_CONFIG_DIR` | Where the key is kept (default `~/.config/tnb`, `%APPDATA%\tnb` on Windows). |
76
+ | `TNB_CACHE_DIR` | Where uploaded files are remembered (default `~/.cache/tnb`). |
77
+
78
+ MIT.
package/bin/tnb.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../lib/main.js";
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => process.exit(code ?? 0),
6
+ (e) => {
7
+ process.stderr.write(JSON.stringify({ error: { code: "cli_crash", message: e instanceof Error ? e.message : String(e) } }) + "\n");
8
+ process.exit(1);
9
+ },
10
+ );
package/lib/api.js ADDED
@@ -0,0 +1,55 @@
1
+ import { createRequire } from "node:module";
2
+ import { BASE_URL, NO_KEY, apiKey } from "./config.js";
3
+
4
+ const { version } = createRequire(import.meta.url)("../package.json");
5
+ export const VERSION = version;
6
+
7
+ /* ONE CALL, ONE SHAPE (2026-09-12). Every command goes through here:
8
+ the bearer key, the client header the platform counts doors by, the
9
+ JSON body, and the platform's own { error: { code, message } } handed
10
+ back as a thrown ApiError so the command prints it and exits 1. The
11
+ CLI never invents an error the API did not say. */
12
+
13
+ export class ApiError extends Error {
14
+ constructor(code, message, status, details) {
15
+ super(message);
16
+ this.code = code;
17
+ this.status = status;
18
+ this.details = details;
19
+ }
20
+ }
21
+
22
+ export async function call(method, path, { body, form, query, headers: extra, auth = true } = {}) {
23
+ const url = new URL(BASE_URL + path);
24
+ for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
25
+ const headers = { accept: "application/json", "user-agent": `tnb-cli/${VERSION}`, "x-tnb-client": "cli", ...(extra ?? {}) };
26
+ if (auth) {
27
+ const k = await apiKey();
28
+ if (!k) throw new ApiError(NO_KEY.code, NO_KEY.message, 0);
29
+ headers.authorization = `Bearer ${k.key}`;
30
+ }
31
+ let payload;
32
+ if (form) payload = form;
33
+ else if (body !== undefined) { headers["content-type"] = "application/json"; payload = JSON.stringify(body); }
34
+ let res;
35
+ try {
36
+ res = await fetch(url, { method, headers, body: payload });
37
+ } catch (e) {
38
+ throw new ApiError("network", `Could not reach ${url.host}: ${e instanceof Error ? e.message : String(e)}`, 0);
39
+ }
40
+ const text = await res.text();
41
+ let data = null;
42
+ try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text.slice(0, 400) }; }
43
+ if (!res.ok) {
44
+ const err = data && typeof data === "object" && data.error;
45
+ if (err && typeof err === "object") throw new ApiError(err.code ?? `http_${res.status}`, err.message ?? `HTTP ${res.status}`, res.status, err.details);
46
+ throw new ApiError(`http_${res.status}`, typeof err === "string" ? err : `HTTP ${res.status} from ${path}`, res.status);
47
+ }
48
+ return data;
49
+ }
50
+
51
+ export const get = (path, query) => call("GET", path, { query });
52
+ export const post = (path, body) => call("POST", path, { body });
53
+ export const patch = (path, body) => call("PATCH", path, { body });
54
+ export const put = (path, body) => call("PUT", path, { body });
55
+ export const del = (path) => call("DELETE", path);
package/lib/args.js ADDED
@@ -0,0 +1,59 @@
1
+ /* THE COMMAND LINE, READ ONCE (2026-09-12).
2
+ *
3
+ * tnb <words…> [positionals…] [--flag value] [--flag=value] [--switch] [--no-switch]
4
+ *
5
+ * A flag given twice becomes a list — that is how a multiple image slot
6
+ * takes several files: `--product_images a.jpg --product_images b.jpg`,
7
+ * or in one go: `--product_images a.jpg b.jpg` (values keep attaching to
8
+ * the last flag until the next `--`). Dashes and underscores are the
9
+ * same flag: `--model-image` and `--model_image` both name the
10
+ * catalogue's `model_image`. `--` ends the flags. No dependency: the
11
+ * whole CLI must install in a second with `npx`. */
12
+
13
+ /**
14
+ * @param {string[]} argv
15
+ * @returns {{ positionals: string[], flags: Record<string, any> }}
16
+ */
17
+ export function parse(argv) {
18
+ /** @type {string[]} */
19
+ const positionals = [];
20
+ /** @type {Record<string, any>} */
21
+ const flags = {};
22
+ let last = null;
23
+ let raw = false;
24
+ const push = (name, value) => {
25
+ const k = name.replace(/-/g, "_");
26
+ if (k in flags) flags[k] = Array.isArray(flags[k]) ? [...flags[k], value] : [flags[k], value];
27
+ else flags[k] = value;
28
+ };
29
+ for (const a of argv) {
30
+ if (raw) { positionals.push(a); continue; }
31
+ if (a === "--") { raw = true; last = null; continue; }
32
+ if (a.startsWith("--")) {
33
+ const eq = a.indexOf("=");
34
+ if (eq > 0) { push(a.slice(2, eq), a.slice(eq + 1)); last = null; continue; }
35
+ const name = a.slice(2);
36
+ if (name.startsWith("no-") || name.startsWith("no_")) { push(name.slice(3), false); last = null; continue; }
37
+ push(name, true);
38
+ last = name;
39
+ continue;
40
+ }
41
+ if (a === "-h") { push("help", true); last = null; continue; }
42
+ if (last) {
43
+ /* The value of the last flag: `true` was a placeholder. */
44
+ const k = last.replace(/-/g, "_");
45
+ if (flags[k] === true) flags[k] = a;
46
+ else if (Array.isArray(flags[k]) && flags[k][flags[k].length - 1] === true) flags[k][flags[k].length - 1] = a;
47
+ else push(last, a);
48
+ continue;
49
+ }
50
+ positionals.push(a);
51
+ }
52
+ return { positionals, flags };
53
+ }
54
+
55
+ /** One value or undefined, never a list. */
56
+ export const one = (v) => (Array.isArray(v) ? v[v.length - 1] : v);
57
+
58
+ /** A list, possibly empty; `true` (a flag with no value) counts as nothing. */
59
+ export const many = (v) => (v === undefined || v === true ? [] : Array.isArray(v) ? v.filter((x) => x !== true) : [v]);
package/lib/catalog.js ADDED
@@ -0,0 +1,44 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { call } from "./api.js";
5
+
6
+ /* THE CATALOGUE, READ LIVE (owner, 2026-09-12: "un workflow ajouté hier
7
+ est dans l'aide aujourd'hui, sans version nouvelle"). Public, no key.
8
+ Cached five minutes in the temp directory — the platform's own CDN
9
+ cadence — so `tnb workflows` twice in a row costs one request. */
10
+
11
+ const TTL_MS = 5 * 60 * 1000;
12
+ const cacheFile = () => path.join(os.tmpdir(), `tnb-catalog-${process.getuid?.() ?? "u"}.json`);
13
+
14
+ export async function catalog({ fresh = false } = {}) {
15
+ if (!fresh) {
16
+ try {
17
+ const st = await fs.stat(cacheFile());
18
+ if (Date.now() - st.mtimeMs < TTL_MS) return JSON.parse(await fs.readFile(cacheFile(), "utf8"));
19
+ } catch { /* no cache, or stale */ }
20
+ }
21
+ const data = await call("GET", "/catalog", { auth: false });
22
+ try { await fs.writeFile(cacheFile(), JSON.stringify(data)); } catch { /* the cache is a convenience */ }
23
+ return data;
24
+ }
25
+
26
+ /** A workflow by key, or by its versioned endpoint name. */
27
+ export async function workflow(name) {
28
+ const c = await catalog();
29
+ const want = String(name ?? "").trim();
30
+ return c.workflows.find((w) => w.key === want || w.endpoint === want) ?? null;
31
+ }
32
+
33
+ /** The short line of a workflow, for lists. */
34
+ export const line = (w) => ({
35
+ key: w.key,
36
+ title: w.title,
37
+ mode: w.mode,
38
+ credits: w.credits,
39
+ prompt: w.takes_prompt,
40
+ images: w.images.map((i) => i.param + (i.required ? "" : "?") + (i.multiple ? `[${i.max}]` : "")),
41
+ fields: w.fields.map((f) => f.name),
42
+ ...(w.variants.length ? { variants: w.variants } : {}),
43
+ ...(w.requires_plan ? { plan: "required" } : {}),
44
+ });
package/lib/config.js ADDED
@@ -0,0 +1,45 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ /* WHERE THE KEY LIVES (2026-09-12). `TNB_API_KEY` in the environment
6
+ wins — a cron, a CI job, a container set it once and never write a
7
+ file. Otherwise `tnb login` keeps it in the user's config directory,
8
+ mode 600, like every CLI that holds a token. Never in the project
9
+ folder, never in a shell history if `tnb login` asked for it. */
10
+
11
+ export const BASE_URL = (process.env.TNB_API_URL || "https://thenewblack.ai/api/v1").replace(/\/$/, "");
12
+ export const SITE_URL = process.env.TNB_SITE_URL || (BASE_URL.endsWith("/api/v1") ? BASE_URL.slice(0, -"/api/v1".length) : "https://thenewblack.ai");
13
+ export const KEYS_PAGE = `${SITE_URL}/profile?tab=api`;
14
+
15
+ export function configDir() {
16
+ if (process.env.TNB_CONFIG_DIR) return process.env.TNB_CONFIG_DIR;
17
+ if (process.platform === "win32") return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "tnb");
18
+ return path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "tnb");
19
+ }
20
+ const configFile = () => path.join(configDir(), "config.json");
21
+
22
+ export async function readConfig() {
23
+ try {
24
+ return JSON.parse(await fs.readFile(configFile(), "utf8"));
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ export async function writeConfig(cfg) {
31
+ await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
32
+ await fs.writeFile(configFile(), JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
33
+ return configFile();
34
+ }
35
+
36
+ /** The key to use, or null. Says where it came from so `whoami` can tell. */
37
+ export async function apiKey() {
38
+ const env = (process.env.TNB_API_KEY || "").trim();
39
+ if (env) return { key: env, from: "TNB_API_KEY" };
40
+ const cfg = await readConfig();
41
+ if (typeof cfg.api_key === "string" && cfg.api_key) return { key: cfg.api_key, from: configFile() };
42
+ return null;
43
+ }
44
+
45
+ export const NO_KEY = { code: "no_api_key", message: `No API key. Run \`tnb login\` or set TNB_API_KEY. Your key is in your profile, API tab: ${KEYS_PAGE}` };
@@ -0,0 +1,141 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { get, post } from "./api.js";
4
+ import { many, one } from "./args.js";
5
+ import { resolveImage } from "./upload.js";
6
+ import { UsageError } from "./output.js";
7
+
8
+ /* ONE COMMAND PER WORKFLOW, GENERATED FROM THE CONTRACT (owner +
9
+ Strategy, 2026-09-12): `tnb generate <key> --<image_param> file|url …
10
+ --<field> value … --prompt … --ratio --tier --variant --duration
11
+ --end-image --webhook --wait --out`. The flags are the catalogue's own
12
+ names, read at call time; nothing here knows a workflow by heart. The
13
+ platform validates the call again before any credit moves, with the
14
+ same words. */
15
+
16
+ const COMMON = new Set(["prompt", "ratio", "tier", "variant", "duration", "end_image", "webhook", "webhook_url", "wait", "out", "project", "pretty", "help", "timeout", "json"]);
17
+
18
+ /** The flags a workflow accepts, for the help and for the refusal of a typo. */
19
+ export function flagsOf(w) {
20
+ const list = [];
21
+ for (const i of w.images) list.push({ flag: `--${i.param}`, value: i.multiple ? `<file|url> ×${i.max}` : "<file|url>", required: i.required, help: i.label });
22
+ if (w.end_image) list.push({ flag: "--end-image", value: "<file|url>", required: false, help: "the last frame" });
23
+ if (w.takes_prompt) list.push({ flag: "--prompt", value: "<text>", required: w.images.every((i) => !i.required), help: w.fields.length ? "the subject's description" : "what to make" });
24
+ for (const f of w.fields) list.push({ flag: `--${f.name}`, value: f.options?.length ? f.options.join("|") : "<text>", required: !!f.required, help: f.hint ?? f.label });
25
+ if (w.variants.length) list.push({ flag: "--variant", value: w.variants.map((v) => JSON.stringify(v)).join("|"), required: false, help: "which variant" });
26
+ if (w.ratios.length) list.push({ flag: "--ratio", value: w.ratios.join("|"), required: false, help: w.mode === "image" ? "portrait 9:16 when nothing is given and no reference" : "the format" });
27
+ if (w.durations.length) list.push({ flag: "--duration", value: w.durations.join("|"), required: false, help: "seconds" });
28
+ list.push({ flag: "--tier", value: "standard|pro", required: false, help: `credits ${w.credits.standard} / ${w.credits.pro} ${w.credits.unit}` });
29
+ list.push({ flag: "--project", value: "<id>", required: false, help: "file the uploads under this project" });
30
+ list.push({ flag: "--webhook", value: "<url>", required: false, help: "called when the generation settles" });
31
+ list.push({ flag: "--wait", value: "", required: false, help: "wait for the result and print its url" });
32
+ list.push({ flag: "--out", value: "<file|dir/>", required: false, help: "with --wait: save the result there (readable name in a dir)" });
33
+ return list;
34
+ }
35
+
36
+ /**
37
+ * The body of POST /generate from the flags — pure, given an image resolver.
38
+ * @param {any} w the workflow's contract from the catalogue
39
+ * @param {Record<string, any>} flags
40
+ * @param {(v: string, opts: { project?: string }) => Promise<{ url: string, file?: string }>} resolve
41
+ * @returns {Promise<{ body: Record<string, any>, inputs: string[] }>}
42
+ */
43
+ export async function buildBody(w, flags, resolve) {
44
+ const known = new Set([...w.images.map((i) => i.param), ...w.fields.map((f) => f.name), ...COMMON]);
45
+ const unknown = Object.keys(flags).filter((k) => !known.has(k));
46
+ if (unknown.length) throw new UsageError(`Unknown flag${unknown.length > 1 ? "s" : ""} for ${w.key}: ${unknown.map((k) => `--${k}`).join(", ")}. Run \`tnb generate ${w.key} --help\`.`);
47
+ /** @type {Record<string, any>} */
48
+ const body = { workflow: w.endpoint };
49
+ const inputs = [];
50
+ const project = one(flags.project);
51
+ for (const i of w.images) {
52
+ const vals = many(flags[i.param]);
53
+ if (!vals.length) { if (i.required) throw new UsageError(`--${i.param} is required (${i.label}).`); continue; }
54
+ if (!i.multiple && vals.length > 1) throw new UsageError(`--${i.param} takes one picture.`);
55
+ if (vals.length > i.max) throw new UsageError(`--${i.param} takes ${i.max} pictures at most.`);
56
+ const urls = [];
57
+ for (const v of vals) { const r = await resolve(v, { project }); urls.push(r.url); if (r.file) inputs.push(r.file); }
58
+ body[i.param] = i.multiple ? urls : urls[0];
59
+ }
60
+ if (w.end_image && flags.end_image !== undefined) { const r = await resolve(one(flags.end_image), { project }); body.end_image = r.url; }
61
+ const prompt = one(flags.prompt);
62
+ if (typeof prompt === "string" && prompt.trim()) {
63
+ if (!w.takes_prompt) throw new UsageError(`${w.key} takes no prompt.`);
64
+ body.prompt = prompt.trim();
65
+ } else if (w.takes_prompt && !w.images.some((i) => body[i.param])) throw new UsageError(`--prompt is required for ${w.key}.`);
66
+ const fields = {};
67
+ for (const f of w.fields) {
68
+ const v = one(flags[f.name]);
69
+ if (v === undefined || v === true) { if (f.required) throw new UsageError(`--${f.name} is required (${f.label}).`); continue; }
70
+ if (f.options?.length && !f.options.includes(String(v))) throw new UsageError(`--${f.name} must be one of ${f.options.join(", ")}.`);
71
+ fields[f.name] = String(v);
72
+ }
73
+ if (Object.keys(fields).length) body.fields = fields;
74
+ for (const k of ["ratio", "tier", "variant", "duration"]) { const v = one(flags[k]); if (typeof v === "string" && v) body[k] = v; }
75
+ if (body.ratio && w.ratios.length && !w.ratios.includes(body.ratio)) throw new UsageError(`--ratio must be one of ${w.ratios.join(", ")}.`);
76
+ if (body.duration && w.durations.length && !w.durations.includes(String(body.duration))) throw new UsageError(`--duration must be one of ${w.durations.join(", ")}.`);
77
+ if (body.tier && !["standard", "pro"].includes(body.tier)) throw new UsageError("--tier is standard or pro.");
78
+ const hook = one(flags.webhook) ?? one(flags.webhook_url);
79
+ if (typeof hook === "string" && hook) body.webhook_url = hook;
80
+ return { body, inputs };
81
+ }
82
+
83
+ /** Poll a generation until it settles. */
84
+ export async function waitFor(id, { timeoutMs = 10 * 60 * 1000, everyMs = 3000 } = {}) {
85
+ const started = Date.now();
86
+ for (;;) {
87
+ const g = await get(`/generations/${id}`);
88
+ if (g.status === "succeeded" || g.status === "failed" || g.status === "canceled") return g;
89
+ if (Date.now() - started > timeoutMs) return { ...g, status: g.status, timed_out: true };
90
+ await new Promise((r) => setTimeout(r, everyMs));
91
+ }
92
+ }
93
+
94
+ const extOf = (url, type) => {
95
+ const m = /\.(webp|png|jpe?g|mp4|webm|gif|svg|glb)(?:$|\?)/i.exec(url);
96
+ return m ? m[1].toLowerCase() : type === "video" ? "mp4" : "webp";
97
+ };
98
+
99
+ /** Save a result url under `out` — a file path, or a directory (readable name from the first input). */
100
+ export async function download(url, out, { inputs = [], workflow = "result", id = "" }, type) {
101
+ const res = await fetch(url);
102
+ if (!res.ok) throw Object.assign(new Error(`Could not download the result (${res.status}).`), { code: "download_failed" });
103
+ const bytes = Buffer.from(await res.arrayBuffer());
104
+ let target = out;
105
+ const isDir = out.endsWith("/") || out.endsWith(path.sep) || (await fs.stat(out).then((s) => s.isDirectory()).catch(() => false));
106
+ if (isDir) {
107
+ const base = inputs[0] ? path.basename(inputs[0], path.extname(inputs[0])) : id.slice(0, 8);
108
+ target = path.join(out, `${base}-${workflow}.${extOf(url, type)}`);
109
+ let n = 1;
110
+ while (await fs.stat(target).then(() => true).catch(() => false)) target = path.join(out, `${base}-${workflow}-${++n}.${extOf(url, type)}`);
111
+ }
112
+ await fs.mkdir(path.dirname(target), { recursive: true });
113
+ await fs.writeFile(target, bytes);
114
+ return target;
115
+ }
116
+
117
+ /** The whole gesture: build, submit, optionally wait and save. */
118
+ export async function generate(w, flags) {
119
+ const { body, inputs } = await buildBody(w, flags, resolveImage);
120
+ const started = await post("/generate", body);
121
+ const out = { generation_id: started.generation_id, status: started.status, workflow: w.key };
122
+ if (!flags.wait && !flags.out) return { ...out, poll: `tnb status ${started.generation_id} --wait` };
123
+ const timeout = Number(one(flags.timeout));
124
+ const g = await waitFor(started.generation_id, { timeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout * 1000 : undefined, everyMs: w.mode === "video" ? 5000 : 3000 });
125
+ return settle(g, { inputs, workflow: w.key, out: one(flags.out) });
126
+ }
127
+
128
+ /** Turn a settled generation into the short answer, saving the file if asked. */
129
+ export async function settle(g, { inputs = [], workflow, out }) {
130
+ const answer = { generation_id: g.generation_id, status: g.status, workflow: g.workflow ?? workflow };
131
+ if (g.status === "succeeded" && g.result?.url) {
132
+ answer.url = g.result.url;
133
+ answer.type = g.result.type;
134
+ if (typeof out === "string" && out) answer.file = await download(g.result.url, out, { inputs, workflow: answer.workflow, id: g.generation_id }, g.result.type);
135
+ } else if (g.status === "failed") {
136
+ answer.error = g.error ?? { code: "failed", message: "The generation failed — the credits were refunded." };
137
+ } else if (g.timed_out) {
138
+ answer.note = "still running; check later with `tnb status <id> --wait`";
139
+ }
140
+ return answer;
141
+ }
package/lib/help.js ADDED
@@ -0,0 +1,57 @@
1
+ import { VERSION } from "./api.js";
2
+ import { ROUTES } from "./routes.js";
3
+ import { flagsOf } from "./generate.js";
4
+
5
+ /* THE HELP, HALF WRITTEN HERE, HALF READ LIVE. What never changes is
6
+ below; what a workflow takes comes from the catalogue at the moment
7
+ of the call (`tnb generate <key> --help`). Text for a person, on
8
+ stderr, so a `--help` never pollutes a JSON pipe. */
9
+
10
+ export function general() {
11
+ const lines = [
12
+ `tnb ${VERSION} — The New Black AI from the command line. JSON out; --pretty to indent.`,
13
+ "",
14
+ " tnb login [--key tnb_live_…] keep your API key (or set TNB_API_KEY)",
15
+ " tnb logout forget it",
16
+ " tnb whoami which account, which key",
17
+ "",
18
+ " tnb workflows [--fresh] every workflow of the studio, with prices",
19
+ " tnb workflows <key> one workflow's contract",
20
+ " tnb generate <key> --help the flags of one workflow, read live",
21
+ " tnb generate <key> [--<image> file|url …] [--<field> value …] [--prompt …] [--wait] [--out dir/]",
22
+ " tnb status <generation_id> [--wait] [--out dir/]",
23
+ " tnb upload <file…> [--project id] pictures → urls and media ids",
24
+ "",
25
+ ];
26
+ const width = Math.max(...ROUTES.map((r) => r.words.join(" ").length + (r.args ?? []).map((a) => ` <${a}>`).join("").length)) + 2;
27
+ for (const r of ROUTES) {
28
+ if (r.special === "status" || r.special === "upload") continue;
29
+ const left = (r.words.join(" ") + (r.args ?? []).map((a) => ` <${a}>`).join("")).padEnd(width);
30
+ lines.push(` tnb ${left}${r.help}`);
31
+ }
32
+ lines.push("", "Keys: https://thenewblack.ai/profile?tab=api · Docs: https://thenewblack.ai/clothing_fashion_api_integrations");
33
+ return lines.join("\n");
34
+ }
35
+
36
+ export function forRoute(r) {
37
+ const flags = Object.entries(r.flags ?? {}).map(([k, s]) => `--${k.replace(/_/g, "-")}${/!/.test(s) ? " (required)" : ""}`);
38
+ return [
39
+ `tnb ${r.words.join(" ")}${(r.args ?? []).map((a) => ` <${a}>`).join("")}${flags.length ? " " + flags.join(" ") : ""}`,
40
+ ` ${r.help}`,
41
+ ` ${r.method} ${r.path} · scope ${r.scope}`,
42
+ ].join("\n");
43
+ }
44
+
45
+ export function forWorkflow(w) {
46
+ const flags = flagsOf(w);
47
+ const width = Math.max(...flags.map((f) => `${f.flag} ${f.value}`.length)) + 2;
48
+ return [
49
+ `tnb generate ${w.key} — ${w.title} (${w.mode}; ${w.credits.standard} / ${w.credits.pro} credits ${w.credits.unit})`,
50
+ w.description ? ` ${w.description}` : null,
51
+ "",
52
+ ...flags.map((f) => ` ${`${f.flag} ${f.value}`.padEnd(width)}${f.required ? "required — " : ""}${f.help}`),
53
+ "",
54
+ " Pictures are local files (uploaded once) or https urls. Results live 48 hours on our url: --out saves the file.",
55
+ ` Docs: https://thenewblack.ai/clothing_fashion_api_integrations`,
56
+ ].filter((l) => l !== null).join("\n");
57
+ }
package/lib/main.js ADDED
@@ -0,0 +1,205 @@
1
+ import { promises as fs } from "node:fs";
2
+ import readline from "node:readline";
3
+ import { spawn } from "node:child_process";
4
+ import { parse, one, many } from "./args.js";
5
+ import { ApiError, VERSION, call, get } from "./api.js";
6
+ import { KEYS_PAGE, apiKey, readConfig, writeConfig } from "./config.js";
7
+ import { catalog, line, workflow } from "./catalog.js";
8
+ import { generate, settle, waitFor } from "./generate.js";
9
+ import { ROUTES, match, spec } from "./routes.js";
10
+ import { general, forRoute, forWorkflow } from "./help.js";
11
+ import { UsageError, print, printError, say, setPretty } from "./output.js";
12
+ import { resolveImage, uploadFile } from "./upload.js";
13
+
14
+ /* THE DISPATCH. Three kinds of command: the key (login, logout, whoami),
15
+ the workflows (generate, workflows — from the catalogue), and the
16
+ fixed routes (the table in routes.js). Exit codes: 0 done, 1 refused
17
+ by the platform or the network, 2 the command line was wrong. */
18
+
19
+ export async function main(argv) {
20
+ const { positionals, flags } = parse(argv);
21
+ setPretty(!!flags.pretty);
22
+ try {
23
+ if (flags.version || positionals[0] === "version") { print({ tnb: VERSION }); return 0; }
24
+ const [cmd] = positionals;
25
+ if (!cmd || cmd === "help") { say(general()); return flags.help || cmd === "help" ? 0 : 2; }
26
+ if (cmd === "login") return await login(flags);
27
+ if (cmd === "logout") return await logout();
28
+ if (cmd === "whoami") return await whoami();
29
+ if (cmd === "workflows") return await workflows(positionals[1], flags);
30
+ if (cmd === "generate") return await runGenerate(positionals[1], flags);
31
+ const route = match(positionals);
32
+ if (!route) throw new UsageError(`Unknown command "${positionals.join(" ")}". Run \`tnb --help\`.`);
33
+ if (flags.help) { say(forRoute(route)); return 0; }
34
+ const rest = positionals.slice(route.words.length);
35
+ if (route.special === "status") return await status(rest[0], flags);
36
+ if (route.special === "upload") return await upload(rest, flags);
37
+ if (route.special === "from-photos") return await fromPhotos(rest, flags);
38
+ return await runRoute(route, rest, flags);
39
+ } catch (e) {
40
+ if (e instanceof UsageError) { printError("usage", e.message); return 2; }
41
+ if (e instanceof ApiError) { printError(e.code, e.message, e.details ? { details: e.details } : undefined); return 1; }
42
+ if (e && typeof e === "object" && "code" in e && typeof e.message === "string") { printError(String(e.code), e.message); return 1; }
43
+ throw e;
44
+ }
45
+ }
46
+
47
+ /* ── the key ─────────────────────────────────────────────────────── */
48
+
49
+ async function ask(question) {
50
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
51
+ return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(a.trim()); }));
52
+ }
53
+
54
+ function openBrowser(url) {
55
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
56
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
57
+ try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); return true; } catch { return false; }
58
+ }
59
+
60
+ async function login(flags) {
61
+ let key = one(flags.key);
62
+ if (typeof key !== "string" || !key) {
63
+ if (flags.browser !== false && process.stdin.isTTY) {
64
+ say(`Your API key is in your profile, API tab: ${KEYS_PAGE}`);
65
+ if (!openBrowser(KEYS_PAGE)) say("(open that address in a browser)");
66
+ } else say(`Your API key is in your profile, API tab: ${KEYS_PAGE}`);
67
+ if (!process.stdin.isTTY) throw new UsageError("No terminal to ask for the key: run `tnb login --key tnb_live_…` or set TNB_API_KEY.");
68
+ key = await ask("Paste the key (tnb_live_…): ");
69
+ }
70
+ if (!/^tnb_/.test(key)) throw new UsageError("That does not look like a key: it starts with tnb_live_.");
71
+ print(await verifyAndSave(key));
72
+ return 0;
73
+ }
74
+
75
+ /* Verified before it is kept: a mistyped key would fail at the first
76
+ real command, later, elsewhere. */
77
+ async function verifyAndSave(key) {
78
+ const before = process.env.TNB_API_KEY;
79
+ process.env.TNB_API_KEY = key;
80
+ let c;
81
+ try { c = await get("/credits"); } finally { if (before === undefined) delete process.env.TNB_API_KEY; else process.env.TNB_API_KEY = before; }
82
+ const cfg = await readConfig();
83
+ const file = await writeConfig({ ...cfg, api_key: key });
84
+ return { ok: true, credits: c.credits, saved_to: file };
85
+ }
86
+
87
+ async function logout() {
88
+ const cfg = await readConfig();
89
+ delete cfg.api_key;
90
+ const file = await writeConfig(cfg);
91
+ print({ ok: true, cleared: file });
92
+ return 0;
93
+ }
94
+
95
+ async function whoami() {
96
+ const k = await apiKey();
97
+ if (!k) { print({ logged_in: false, hint: "tnb login, or TNB_API_KEY" }); return 1; }
98
+ const a = await get("/account");
99
+ print({ logged_in: true, key_from: k.from, key_prefix: k.key.slice(0, 14), account: a.id, username: a.username, credits: a.credits, team: a.team?.role });
100
+ return 0;
101
+ }
102
+
103
+ /* ── the workflows ───────────────────────────────────────────────── */
104
+
105
+ async function workflows(key, flags) {
106
+ if (key) {
107
+ const w = await workflow(key);
108
+ if (!w) throw new UsageError(`No workflow "${key}". Run \`tnb workflows\`.`);
109
+ if (flags.help) { say(forWorkflow(w)); return 0; }
110
+ print(w);
111
+ return 0;
112
+ }
113
+ const c = await catalog({ fresh: !!flags.fresh });
114
+ print({ count: c.workflows.length, workflows: c.workflows.map(line) });
115
+ return 0;
116
+ }
117
+
118
+ async function runGenerate(key, flags) {
119
+ if (!key) {
120
+ const c = await catalog();
121
+ say(`tnb generate <key> [flags] — one of:\n${c.workflows.map((w) => ` ${w.key.padEnd(34)}${w.title}`).join("\n")}\n\ntnb generate <key> --help shows the flags of one.`);
122
+ return 2;
123
+ }
124
+ const w = await workflow(key);
125
+ if (!w) throw new UsageError(`No workflow "${key}". Run \`tnb workflows\`.`);
126
+ if (flags.help) { say(forWorkflow(w)); return 0; }
127
+ const answer = await generate(w, flags);
128
+ print(answer);
129
+ return answer.status === "failed" ? 1 : 0;
130
+ }
131
+
132
+ async function status(id, flags) {
133
+ if (!id) throw new UsageError("tnb status <generation_id> [--wait] [--out dir/]");
134
+ const g = flags.wait || flags.out ? await waitFor(id) : await get(`/generations/${id}`);
135
+ const answer = await settle(g, { out: one(flags.out) });
136
+ print(answer);
137
+ return answer.status === "failed" ? 1 : 0;
138
+ }
139
+
140
+ async function upload(files, flags) {
141
+ if (!files.length) throw new UsageError("tnb upload <file…> [--project <id>]");
142
+ const project = one(flags.project);
143
+ const out = [];
144
+ for (const f of files) { const u = await uploadFile(f, { project }); out.push({ file: f, media_id: u.media_id, url: u.url, ...(u.cached ? { cached: true } : {}) }); }
145
+ print(files.length === 1 ? out[0] : { uploads: out });
146
+ return 0;
147
+ }
148
+
149
+ async function fromPhotos(files, flags) {
150
+ const project = one(flags.project);
151
+ const images = [];
152
+ for (const f of files) images.push((await resolveImage(f, { project })).url);
153
+ if (!images.length) throw new UsageError("tnb techpack from-photos <front.jpg> [more.jpg …] [--back back.jpg] [--sketch none|front|front_back] [--size-range XS-XL] [--unit cm|in] [--description …] [--product …] [--project <id>]");
154
+ const body = { images };
155
+ const back = one(flags.back);
156
+ if (typeof back === "string" && back) body.back = (await resolveImage(back, { project })).url;
157
+ for (const k of ["description", "product", "size_range", "unit", "sketch"]) { const v = one(flags[k]); if (typeof v === "string" && v) body[k] = v; }
158
+ if (typeof project === "string" && project) body.project_id = project;
159
+ const r = await call("POST", "/techpacks/from-photos", { body });
160
+ print(r);
161
+ return 0;
162
+ }
163
+
164
+ /* ── the fixed routes ────────────────────────────────────────────── */
165
+
166
+ async function jsonArg(v) {
167
+ const s = String(v);
168
+ const text = s.startsWith("@") ? await fs.readFile(s.slice(1), "utf8") : s;
169
+ try { return JSON.parse(text); } catch { throw new UsageError(`Not JSON: ${s.slice(0, 60)}${s.length > 60 ? "…" : ""}`); }
170
+ }
171
+
172
+ export async function runRoute(route, rest, flags) {
173
+ const args = route.args ?? [];
174
+ if (rest.length < args.length) throw new UsageError(`tnb ${route.words.join(" ")}${args.map((a) => ` <${a}>`).join("")} — missing ${args.slice(rest.length).map((a) => `<${a}>`).join(", ")}.`);
175
+ let path = route.path;
176
+ const body = {};
177
+ const query = {};
178
+ const headers = {};
179
+ args.forEach((a, i) => { if (path.includes(`{${a}}`)) path = path.replace(`{${a}}`, encodeURIComponent(rest[i])); else body[a] = rest[i]; });
180
+ const known = new Set(["pretty", "help", "json"]);
181
+ for (const [flag, s] of Object.entries(route.flags ?? {})) {
182
+ known.add(flag);
183
+ const { where, mod, name } = spec(s);
184
+ const target = name ?? flag;
185
+ const raw = flags[flag];
186
+ if (raw === undefined) { if (mod === "!") throw new UsageError(`--${flag.replace(/_/g, "-")} is required. Run \`tnb ${route.words.join(" ")} --help\`.`); continue; }
187
+ let value;
188
+ if (mod === "[]") value = many(raw);
189
+ else if (mod === "?") value = raw !== false && raw !== "false";
190
+ else if (mod === "#") { value = Number(one(raw)); if (!Number.isInteger(value)) throw new UsageError(`--${flag} must be an integer.`); }
191
+ else if (mod === "@") value = await jsonArg(one(raw));
192
+ else { value = one(raw); if (value === true) throw new UsageError(`--${flag.replace(/_/g, "-")} needs a value.`); }
193
+ if (where === "q") query[target] = value;
194
+ else if (where === "h") headers[target] = value;
195
+ else body[target] = value;
196
+ }
197
+ const unknown = Object.keys(flags).filter((k) => !known.has(k));
198
+ if (unknown.length) throw new UsageError(`Unknown flag${unknown.length > 1 ? "s" : ""}: ${unknown.map((k) => `--${k}`).join(", ")}. Run \`tnb ${route.words.join(" ")} --help\`.`);
199
+ const hasBody = route.method !== "GET" && route.method !== "DELETE";
200
+ const data = await call(route.method, path, { query, body: hasBody ? body : undefined, headers });
201
+ print(data);
202
+ return 0;
203
+ }
204
+
205
+ export { ROUTES };
package/lib/output.js ADDED
@@ -0,0 +1,24 @@
1
+ /* WHAT LEAVES THE COMMAND (owner, 2026-09-12: "une bonne commande rend
2
+ le moins de texte possible — identifiant, URL, statut, erreur
3
+ lisible ; pas de bavardage"). JSON, one line, on stdout; `--pretty`
4
+ indents it for a person. Errors are JSON too, on stderr, and the
5
+ exit code says it: 0 done, 1 the platform or the network refused,
6
+ 2 the command line was wrong. */
7
+
8
+ let pretty = false;
9
+ export const setPretty = (v) => { pretty = !!v; };
10
+
11
+ export function print(value) {
12
+ process.stdout.write((pretty ? JSON.stringify(value, null, 2) : JSON.stringify(value)) + "\n");
13
+ }
14
+
15
+ export function printError(code, message, extra) {
16
+ process.stderr.write(JSON.stringify({ error: { code, message, ...(extra ? extra : {}) } }) + "\n");
17
+ }
18
+
19
+ /** Text for a person — help and login prompts — never mixed with JSON. */
20
+ export const say = (text) => process.stderr.write(text.endsWith("\n") ? text : text + "\n");
21
+
22
+ export class UsageError extends Error {
23
+ constructor(message) { super(message); this.code = "usage"; }
24
+ }
package/lib/routes.js ADDED
@@ -0,0 +1,76 @@
1
+ /* ONE COMMAND PER FIXED ROUTE (owner + Strategy, 2026-09-12). The
2
+ * catalogue gives the workflows their commands at call time; the routes
3
+ * that do not change with the catalogue — credits, media, projects, tech
4
+ * packs, moodboards, publishing, Shopify, agents, files — are declared
5
+ * here, one line each, and one executor runs them all. A command is its
6
+ * route: the same words, the same JSON back, nothing added. The OpenAPI
7
+ * document, when it exists, will replace this table.
8
+ *
9
+ * words what the person types after `tnb`
10
+ * args positional arguments, in order, laid into {path} or the body
11
+ * flags --name → where it goes: q (query), b (body), p (path)
12
+ * scope the key scope the platform demands (for the help only)
13
+ */
14
+
15
+ export const ROUTES = [
16
+ { words: ["credits"], method: "GET", path: "/credits", scope: "read", help: "The account's credit balance." },
17
+ { words: ["account"], method: "GET", path: "/account", scope: "read", help: "The account in one reading: credits, team, projects, library, tech packs, Visual DNA." },
18
+ { words: ["ledger"], method: "GET", path: "/ledger", scope: "read", flags: { days: "q", lines: "q" }, help: "What the account spent and received (--days 30 --lines 60)." },
19
+ { words: ["brand-dna"], method: "GET", path: "/brand-dna", scope: "read", help: "The Visual DNA profiles." },
20
+ { words: ["brand-dna", "read"], method: "GET", path: "/brand-dna/{id}", scope: "read", args: ["id"], help: "One Visual DNA profile in full, with its analysis." },
21
+
22
+ { words: ["media"], method: "GET", path: "/media", scope: "read", flags: { project: "q" }, help: "The account's recent creations (--project <id>)." },
23
+ { words: ["elements"], method: "GET", path: "/elements", scope: "read", help: "The starred pot: creations, presets, uploads." },
24
+ { words: ["projects"], method: "GET", path: "/projects", scope: "read", help: "The account's projects." },
25
+ { words: ["status"], method: "GET", path: "/generations/{id}", scope: "read", args: ["id"], help: "A generation's status and result (--wait, --out <file|dir/>).", special: "status" },
26
+
27
+ { words: ["techpacks"], method: "GET", path: "/techpacks", scope: "read", flags: { project: "q" }, help: "The tech packs (--project <id>)." },
28
+ { words: ["techpack"], method: "GET", path: "/techpacks/{id}", scope: "read", args: ["id"], help: "One tech pack whole: pages, sections, data." },
29
+ { words: ["techpack", "pdf"], method: "GET", path: "/techpacks/{id}/pdf", scope: "read", args: ["id"], help: "The tech pack as a PDF (a url)." },
30
+ { words: ["techpack", "rename"], method: "PATCH", path: "/techpacks/{id}", scope: "generate", args: ["id"], flags: { name: "b!" }, help: "Rename a tech pack (--name)." },
31
+ { words: ["techpack", "add-section"], method: "POST", path: "/techpacks/{id}/sections", scope: "generate", args: ["id"], flags: { page: "b!page_id", kind: "b!", at: "b#at_index" }, help: "Add a section to a page (--page <id> --kind header|descriptions|canvas|size_chart|bom|end_notes [--at 0])." },
32
+ { words: ["techpack", "write-section"], method: "PUT", path: "/techpacks/{id}/sections/{section}", scope: "generate", args: ["id", "section"], flags: { data: "b@" }, help: "Replace a section's data (--data '<json>' or --data @file.json)." },
33
+ { words: ["techpack", "from-photos"], method: "POST", path: "/techpacks/from-photos", scope: "generate", special: "from-photos", help: "Start a tech pack with AI from photos: tnb techpack from-photos front.jpg [back.jpg …] [--back <file>] [--description] [--product] [--size-range XS-XL] [--unit cm|in] [--sketch none|front|front_back] [--project <id>]. 1 credit + 1 per sketch." },
34
+
35
+ { words: ["moodboards"], method: "GET", path: "/moodboards", scope: "read", flags: { project: "q" }, help: "The moodboards (--project <id>)." },
36
+ { words: ["moodboard", "pdf"], method: "GET", path: "/moodboards/{id}/pdf", scope: "read", args: ["id"], help: "The moodboard as a PDF (a url)." },
37
+
38
+ { words: ["upload"], method: "POST", path: "/media/upload", scope: "generate", special: "upload", help: "Upload pictures (jpg, png, webp) and get their urls and media ids: tnb upload a.jpg b.png [--project <id>]." },
39
+
40
+ { words: ["shopify", "products"], method: "GET", path: "/shopify/products", scope: "read", help: "The connected store's products." },
41
+ { words: ["shopify", "publish"], method: "POST", path: "/shopify/publish", scope: "publish", flags: { product: "b!product_id", media: "b media_id", url: "b", alt: "b" }, help: "Put a creation on a product page (--product <id> --media <media_id> | --url <creation url> [--alt])." },
42
+
43
+ { words: ["publish", "accounts"], method: "GET", path: "/publish/accounts", scope: "read", help: "The connected social accounts and the placements each takes." },
44
+ { words: ["publish"], method: "POST", path: "/publish", scope: "publish", flags: { account: "b!connection_id", placement: "b!", media: "b[]media_ids", caption: "b", collaborators: "b", scheduled_at: "b", board: "b board_id", board_name: "b", title: "b", link: "b", ai: "b?is_ai_generated", tiktok: "b@" }, help: "Post a creation to a connected account (--account <connection_id> --placement <placement> --media <media_id>… [--caption] [--scheduled-at] [--board] [--title] [--link] [--tiktok '<json>'])." },
45
+ { words: ["post"], method: "GET", path: "/publish/{id}", scope: "read", args: ["id"], help: "A post's state: scheduled, posted with its permalink, failed." },
46
+
47
+ { words: ["agents"], method: "GET", path: "/agents", scope: "agents", help: "The account's AI agents." },
48
+ { words: ["agent", "send"], method: "POST", path: "/agents/{id}/messages", scope: "agents", args: ["id", "message"], flags: { media: "b[]media_ids", idempotency_key: "h Idempotency-Key" }, help: "Say something to an agent: tnb agent send <id> \"message\" [--media <media_id>…] [--idempotency-key <yours>]." },
49
+ { words: ["agent", "thread"], method: "GET", path: "/agents/{id}/messages", scope: "agents", args: ["id"], flags: { since: "q", limit: "q" }, help: "Read the agent's thread (--since <iso date> --limit 50)." },
50
+ { words: ["agent", "stop"], method: "POST", path: "/agents/{id}/stop", scope: "agents", args: ["id"], help: "Stop the agent's running task." },
51
+ { words: ["agent", "schedules"], method: "GET", path: "/agents/{id}/schedules", scope: "agents", args: ["id"], help: "The agent's calendar." },
52
+ { words: ["agent", "schedule"], method: "POST", path: "/agents/{id}/schedules", scope: "agents", args: ["id", "request"], flags: { kind: "b!", time: "b!", date: "b", every: "b", day_of_week: "b#", day_of_month: "b#", may_publish: "b?" }, help: "Put a request on the agent's calendar: tnb agent schedule <id> \"request\" --kind single|recurring --time HH:MM [--date YYYY-MM-DD | --every daily|weekly|monthly --day-of-week 0-6 --day-of-month 1-31] [--may-publish]." },
53
+ { words: ["agent", "unschedule"], method: "DELETE", path: "/agents/{id}/schedules/{schedule}", scope: "agents", args: ["id", "schedule"], help: "Take a request off the agent's calendar." },
54
+
55
+ { words: ["files"], method: "GET", path: "/files", scope: "agents", flags: { query: "q", folder: "q" }, help: "The account's Files (--query <word> --folder <path>)." },
56
+ { words: ["file"], method: "GET", path: "/files/{id}", scope: "agents", args: ["id"], flags: { page: "q", sheet: "q", picture: "q" }, help: "One page of a file, by id or path (--page 1 --sheet <name> --picture <inner name>)." },
57
+ { words: ["file", "describe"], method: "PATCH", path: "/files/{id}", scope: "agents", args: ["id"], flags: { description: "b!" }, help: "Write the one-line description of a file (--description)." },
58
+ ];
59
+
60
+ /** The route whose words start the command line — the longest match wins. */
61
+ export function match(positionals) {
62
+ let best = null;
63
+ for (const r of ROUTES) {
64
+ if (r.words.every((w, i) => positionals[i] === w) && (!best || r.words.length > best.words.length)) best = r;
65
+ }
66
+ return best;
67
+ }
68
+
69
+ /* A flag spec: "<where>[modifier][ name]" — where is q (query), b (body),
70
+ h (header); modifiers: ! required, # integer, ? boolean, [] list, @ JSON
71
+ (inline or @file); the name after a space renames it. */
72
+ export function spec(s) {
73
+ const m = /^([qbh])([!#?@]|\[\])?(?:\s*(\S.*))?$/.exec(s);
74
+ if (!m) throw new Error(`bad flag spec ${s}`);
75
+ return { where: m[1], mod: m[2] ?? "", name: m[3] ?? null };
76
+ }
package/lib/upload.js ADDED
@@ -0,0 +1,60 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { call } from "./api.js";
5
+
6
+ /* A LOCAL FILE BECOMES A URL (2026-09-12). The reference scenario is a
7
+ folder of product photos on the person's machine; the API takes
8
+ https URLs. Anything that is not a URL is read from disk and sent to
9
+ POST /media/upload, and the URL the platform answers is what the
10
+ workflow receives. Uploaded once: the same file (path, size, mtime)
11
+ is remembered in the user's cache, so "do the same with the new ones"
12
+ tomorrow re-uploads nothing. */
13
+
14
+ const isUrl = (v) => /^https?:\/\//i.test(String(v));
15
+ const MIME = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp" };
16
+
17
+ function cacheFile() {
18
+ const dir = process.env.TNB_CACHE_DIR || (process.platform === "win32"
19
+ ? path.join(process.env.LOCALAPPDATA || os.tmpdir(), "tnb")
20
+ : path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache"), "tnb"));
21
+ return path.join(dir, "uploads.json");
22
+ }
23
+
24
+ async function readCache() {
25
+ try { return JSON.parse(await fs.readFile(cacheFile(), "utf8")); } catch { return {}; }
26
+ }
27
+ async function writeCache(c) {
28
+ try {
29
+ await fs.mkdir(path.dirname(cacheFile()), { recursive: true });
30
+ await fs.writeFile(cacheFile(), JSON.stringify(c));
31
+ } catch { /* a cache is a convenience */ }
32
+ }
33
+
34
+ /** Upload one local picture; answers { media_id, url, … }. */
35
+ export async function uploadFile(file, { project } = {}) {
36
+ const abs = path.resolve(file);
37
+ const st = await fs.stat(abs).catch(() => null);
38
+ if (!st || !st.isFile()) throw Object.assign(new Error(`No such file: ${file}`), { code: "no_such_file" });
39
+ const ext = path.extname(abs).toLowerCase();
40
+ const mime = MIME[ext];
41
+ if (!mime) throw Object.assign(new Error(`${file}: only .jpg, .png and .webp pictures can be uploaded.`), { code: "unsupported_file" });
42
+ const cache = await readCache();
43
+ const sig = `${abs}|${st.size}|${Math.floor(st.mtimeMs)}|${project ?? ""}`;
44
+ if (cache[sig]?.url) return { ...cache[sig], cached: true };
45
+ const bytes = await fs.readFile(abs);
46
+ const form = new FormData();
47
+ form.set("file", new Blob([bytes], { type: mime }), path.basename(abs));
48
+ if (project) form.set("project", project);
49
+ const out = await call("POST", "/media/upload", { form });
50
+ cache[sig] = { media_id: out.media_id, url: out.url, name: path.basename(abs) };
51
+ await writeCache(cache);
52
+ return { ...cache[sig], cached: false };
53
+ }
54
+
55
+ /** A URL stays a URL; a path is uploaded. */
56
+ export async function resolveImage(v, opts) {
57
+ if (isUrl(v)) return { url: String(v) };
58
+ const up = await uploadFile(String(v), opts);
59
+ return { url: up.url, media_id: up.media_id, file: String(v) };
60
+ }
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@thenewblack/cli",
3
+ "version": "0.1.0",
4
+ "description": "The New Black AI from the command line: every fashion workflow of the studio, your creations, tech packs, publishing and AI agents — one command each, JSON out. Made for AI coding agents and scripts.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": { "tnb": "bin/tnb.js" },
8
+ "files": ["bin", "lib", "README.md"],
9
+ "engines": { "node": ">=18.17" },
10
+ "keywords": ["fashion", "ai", "cli", "thenewblack", "virtual-try-on", "fashion-design", "agent"],
11
+ "homepage": "https://thenewblack.ai/clothing_fashion_api_integrations",
12
+ "repository": { "type": "git", "url": "git+https://github.com/newblackai/tnb-site.git", "directory": "cli" },
13
+ "bugs": { "url": "https://github.com/newblackai/tnb-site/issues" },
14
+ "scripts": {
15
+ "test": "node --test"
16
+ }
17
+ }