@facelessad/cli 1.0.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 +26 -0
- package/index.js +334 -0
- package/package.json +12 -0
package/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# @facelessad/cli
|
|
2
|
+
|
|
3
|
+
Create faceless video ads from your terminal or build scripts.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @facelessad/cli
|
|
7
|
+
facelessad login # paste an API key from facelessad.com/developers
|
|
8
|
+
facelessad tools # what can be made
|
|
9
|
+
facelessad create --tool motion-graphics --url https://your-product.com --wait
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Nine commands: `login`, `create`, `status`, `list`, `download`, `estimate`,
|
|
13
|
+
`tools`, `voices`, `balance`. Every command accepts `--json` for scripting;
|
|
14
|
+
`create`/`estimate` accept `--dry-run` (print the request body without
|
|
15
|
+
sending) and `--file body.json` (a base body that flags override).
|
|
16
|
+
|
|
17
|
+
The key is stored in `~/.facelessad/config.json` with mode 0600. The
|
|
18
|
+
environment variable `FACELESSAD_API_KEY` always wins — that is the way CI
|
|
19
|
+
should authenticate. `FACELESSAD_API_URL` overrides the API base.
|
|
20
|
+
|
|
21
|
+
`--wait` polls and prints phase + elapsed time; for long-running builds a
|
|
22
|
+
webhook is the better tool — a stuck shell is a stuck CI job. See
|
|
23
|
+
https://facelessad.com/developers → Webhooks.
|
|
24
|
+
|
|
25
|
+
Errors are printed with the API's own wording and exit code 1. The machine
|
|
26
|
+
code (e.g. `unknown_style`) follows the message.
|
package/index.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @facelessad/cli — FacelessAd from the terminal.
|
|
4
|
+
*
|
|
5
|
+
* Nine commands, each one /api/v1 call. Zero dependencies: Node 18 ships
|
|
6
|
+
* fetch, and a tool that stores an API key is a bad place for a dependency
|
|
7
|
+
* tree.
|
|
8
|
+
*
|
|
9
|
+
* The key lives in ~/.facelessad/config.json (mode 0600). The environment
|
|
10
|
+
* variable FACELESSAD_API_KEY wins over the file — that is how CI should
|
|
11
|
+
* use this. FACELESSAD_API_URL overrides the API base (testing).
|
|
12
|
+
*
|
|
13
|
+
* Errors surface with the API's own wording and exit code 1, so a pipeline
|
|
14
|
+
* stops where it should. The machine code (e.g. unknown_style) is printed
|
|
15
|
+
* dimmed after the message — readable for a human, greppable for a script.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import readline from 'node:readline';
|
|
22
|
+
|
|
23
|
+
const API = (process.env.FACELESSAD_API_URL || 'https://facelessad.com').replace(/\/+$/, '');
|
|
24
|
+
const CONFIG_DIR = path.join(os.homedir(), '.facelessad');
|
|
25
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
26
|
+
|
|
27
|
+
const isTTY = process.stdout.isTTY;
|
|
28
|
+
const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
29
|
+
const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
30
|
+
|
|
31
|
+
// ────────────────────────── arg parsing ──────────────────────────
|
|
32
|
+
|
|
33
|
+
const rawArgs = process.argv.slice(2);
|
|
34
|
+
const cmd = rawArgs[0] && !rawArgs[0].startsWith('-') ? rawArgs[0] : (rawArgs.includes('--version') || rawArgs.includes('-v') ? 'version' : 'help');
|
|
35
|
+
const flags = {};
|
|
36
|
+
const positional = [];
|
|
37
|
+
for (let i = cmd === 'help' || cmd === 'version' ? 0 : 1; i < rawArgs.length; i++) {
|
|
38
|
+
const a = rawArgs[i];
|
|
39
|
+
if (a.startsWith('--')) {
|
|
40
|
+
const eq = a.indexOf('=');
|
|
41
|
+
if (eq !== -1) {
|
|
42
|
+
flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
43
|
+
} else if (i + 1 < rawArgs.length && !rawArgs[i + 1].startsWith('--')) {
|
|
44
|
+
flags[a.slice(2)] = rawArgs[++i];
|
|
45
|
+
} else {
|
|
46
|
+
flags[a.slice(2)] = true;
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
positional.push(a);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const asJson = flags.json === true || flags.json === 'true';
|
|
53
|
+
|
|
54
|
+
// ────────────────────────── config / auth ──────────────────────────
|
|
55
|
+
|
|
56
|
+
function readConfig() {
|
|
57
|
+
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
|
|
58
|
+
}
|
|
59
|
+
function writeConfig(cfg) {
|
|
60
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
61
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
62
|
+
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch { /* windows */ }
|
|
63
|
+
}
|
|
64
|
+
function apiKey() {
|
|
65
|
+
const k = process.env.FACELESSAD_API_KEY || readConfig().apiKey || '';
|
|
66
|
+
if (!k) {
|
|
67
|
+
die('No API key. Run `facelessad login` or set FACELESSAD_API_KEY. Keys are created at ' + API + '/developers', 'missing_key');
|
|
68
|
+
}
|
|
69
|
+
return k;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function die(message, code) {
|
|
73
|
+
if (asJson) {
|
|
74
|
+
process.stdout.write(JSON.stringify({ ok: false, error: message, code: code || 'error' }) + '\n');
|
|
75
|
+
} else {
|
|
76
|
+
process.stderr.write('Error: ' + message + (code ? ' ' + dim('(' + code + ')') : '') + '\n');
|
|
77
|
+
}
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ────────────────────────── HTTP ──────────────────────────
|
|
82
|
+
|
|
83
|
+
async function call(method, p, body) {
|
|
84
|
+
let res;
|
|
85
|
+
try {
|
|
86
|
+
res = await fetch(API + p, {
|
|
87
|
+
method,
|
|
88
|
+
headers: {
|
|
89
|
+
'Authorization': 'Bearer ' + apiKey(),
|
|
90
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
91
|
+
},
|
|
92
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
93
|
+
});
|
|
94
|
+
} catch (e) {
|
|
95
|
+
die('Could not reach ' + API + ' — ' + (e.cause?.code || e.message), 'network');
|
|
96
|
+
}
|
|
97
|
+
let data = null;
|
|
98
|
+
try { data = await res.json(); } catch { /* non-JSON */ }
|
|
99
|
+
if (!data || data.ok !== true) {
|
|
100
|
+
const msg = data?.error || ('HTTP ' + res.status);
|
|
101
|
+
die(msg, data?.code || 'http_' + res.status);
|
|
102
|
+
}
|
|
103
|
+
return data;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function out(data, human) {
|
|
107
|
+
if (asJson) {
|
|
108
|
+
process.stdout.write(JSON.stringify(data) + '\n');
|
|
109
|
+
} else {
|
|
110
|
+
process.stdout.write(human + '\n');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ────────────────────────── request body from flags ──────────────────────────
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* --file body.json gives the base; flags override it. This is the same body
|
|
118
|
+
* POST /api/v1/videos takes — the web generator's CLI output maps 1:1 here.
|
|
119
|
+
*/
|
|
120
|
+
function buildBody() {
|
|
121
|
+
let body = {};
|
|
122
|
+
if (flags.file) {
|
|
123
|
+
try { body = JSON.parse(fs.readFileSync(String(flags.file), 'utf8')); }
|
|
124
|
+
catch (e) { die('Could not read --file ' + flags.file + ': ' + e.message, 'bad_file'); }
|
|
125
|
+
}
|
|
126
|
+
const set = (k, v) => { if (v !== undefined) body[k] = v; };
|
|
127
|
+
set('tool', flags.tool !== undefined ? String(flags.tool) : undefined);
|
|
128
|
+
if (flags.url !== undefined || flags.text !== undefined) {
|
|
129
|
+
body.materials = body.materials || {};
|
|
130
|
+
if (flags.url !== undefined) body.materials.landing_page_url = String(flags.url);
|
|
131
|
+
if (flags.text !== undefined) body.materials.text = String(flags.text);
|
|
132
|
+
}
|
|
133
|
+
set('duration', flags.duration !== undefined ? parseInt(flags.duration, 10) : undefined);
|
|
134
|
+
set('aspect_ratio', flags.aspect !== undefined ? String(flags.aspect) : undefined);
|
|
135
|
+
set('language', flags.language !== undefined ? String(flags.language) : undefined);
|
|
136
|
+
set('style', flags.style !== undefined ? String(flags.style) : undefined);
|
|
137
|
+
set('style_hint', flags['style-hint'] !== undefined ? String(flags['style-hint']) : undefined);
|
|
138
|
+
set('ad_structure', flags.structure !== undefined ? String(flags.structure) : undefined);
|
|
139
|
+
set('hook_formula', flags.hook !== undefined ? String(flags.hook) : undefined);
|
|
140
|
+
set('video_mode', flags['video-mode'] !== undefined ? String(flags['video-mode']) : undefined);
|
|
141
|
+
set('brand_color', flags['brand-color'] !== undefined ? String(flags['brand-color']) : undefined);
|
|
142
|
+
set('brand_name', flags['brand-name'] !== undefined ? String(flags['brand-name']) : undefined);
|
|
143
|
+
set('cta', flags.cta !== undefined ? String(flags.cta) : undefined);
|
|
144
|
+
set('name', flags.name !== undefined ? String(flags.name) : undefined);
|
|
145
|
+
set('product_image_url', flags['product-image'] !== undefined ? String(flags['product-image']) : undefined);
|
|
146
|
+
if (flags.music !== undefined) body.music = flags.music === true || flags.music === 'true';
|
|
147
|
+
if (flags.sfx !== undefined) body.sfx = flags.sfx === true || flags.sfx === 'true';
|
|
148
|
+
if (flags.captions !== undefined) body.captions = flags.captions === true || flags.captions === 'true';
|
|
149
|
+
if (flags['no-voice'] === true) body.voice_over = false;
|
|
150
|
+
if (flags.voice !== undefined || flags.gender !== undefined) {
|
|
151
|
+
if (typeof flags.voice === 'string' && flags.gender === undefined) {
|
|
152
|
+
body.voice = flags.voice;
|
|
153
|
+
} else {
|
|
154
|
+
body.voice = {};
|
|
155
|
+
if (typeof flags.voice === 'string') body.voice.id = flags.voice;
|
|
156
|
+
if (flags.gender !== undefined) body.voice.gender = String(flags.gender);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return body;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ────────────────────────── commands ──────────────────────────
|
|
163
|
+
|
|
164
|
+
const commands = {
|
|
165
|
+
|
|
166
|
+
async version() {
|
|
167
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
|
168
|
+
out({ ok: true, version: pkg.version }, pkg.version);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
async login() {
|
|
172
|
+
let key = process.env.FACELESSAD_API_KEY || '';
|
|
173
|
+
if (!key) {
|
|
174
|
+
key = await new Promise((resolve) => {
|
|
175
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
176
|
+
rl.question('Paste your API key (' + API + '/developers): ', (a) => { rl.close(); resolve(a.trim()); });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
if (!key) die('No key given', 'missing_key');
|
|
180
|
+
// Verify immediately — a key that silently does not work is worse than
|
|
181
|
+
// no key at all.
|
|
182
|
+
let res;
|
|
183
|
+
try {
|
|
184
|
+
res = await fetch(API + '/api/v1/balance', { headers: { Authorization: 'Bearer ' + key } });
|
|
185
|
+
} catch (e) {
|
|
186
|
+
die('Could not reach ' + API + ' — ' + (e.cause?.code || e.message), 'network');
|
|
187
|
+
}
|
|
188
|
+
const data = await res.json().catch(() => null);
|
|
189
|
+
if (!data || data.ok !== true) {
|
|
190
|
+
die(data?.error || 'That key was rejected (HTTP ' + res.status + ')', data?.code || 'invalid_key');
|
|
191
|
+
}
|
|
192
|
+
writeConfig({ ...readConfig(), apiKey: key });
|
|
193
|
+
out(
|
|
194
|
+
{ ok: true, plan: data.plan ?? null, balance: data.balance ?? null },
|
|
195
|
+
'Logged in. Plan: ' + (data.plan ?? '—') + ', balance: ' + (data.balance ?? '—') + ' credits.\nKey saved to ' + CONFIG_FILE + ' (0600).'
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
async balance() {
|
|
200
|
+
const d = await call('GET', '/api/v1/balance');
|
|
201
|
+
out(d, 'Plan: ' + (d.plan ?? '—') + '\nBalance: ' + (d.balance ?? '—') + ' credits');
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
async tools() {
|
|
205
|
+
const d = await call('GET', '/api/v1/tools');
|
|
206
|
+
if (flags.tool) {
|
|
207
|
+
const t = (d.tools || []).find((x) => x.id === flags.tool);
|
|
208
|
+
if (!t) die('Unknown tool "' + flags.tool + '". Valid: ' + (d.tools || []).map((x) => x.id).join(', '), 'unknown_tool');
|
|
209
|
+
const lines = [bold(t.id) + ' — ' + (t.name || '')];
|
|
210
|
+
if (t.styles?.length) lines.push(' styles: ' + t.styles.map((s) => s.id).join(', '));
|
|
211
|
+
if (t.adStructures?.length) lines.push(' ad structures: ' + t.adStructures.map((s) => s.id).join(', '));
|
|
212
|
+
if (t.durations) lines.push(' duration: ' + t.durations.min + '–' + t.durations.max + ' s');
|
|
213
|
+
out({ ok: true, tool: t }, lines.join('\n'));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
out(d, (d.tools || []).map((t) => ' ' + t.id.padEnd(18) + dim(t.name || '')).join('\n') + '\n\nDetails: facelessad tools --tool <id>');
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
async voices() {
|
|
220
|
+
const q = new URLSearchParams();
|
|
221
|
+
if (flags.language) q.set('language', String(flags.language));
|
|
222
|
+
if (flags.gender) q.set('gender', String(flags.gender));
|
|
223
|
+
const d = await call('GET', '/api/v1/voices' + (q.size ? '?' + q : ''));
|
|
224
|
+
out(d, (d.voices || []).map((v) => ' ' + v.id.padEnd(24) + (v.name || '').padEnd(16) + dim((v.gender || '') + ' ' + (v.language || ''))).join('\n'));
|
|
225
|
+
},
|
|
226
|
+
|
|
227
|
+
async estimate() {
|
|
228
|
+
const body = buildBody();
|
|
229
|
+
if (flags['dry-run'] === true) { out({ ok: true, body }, JSON.stringify(body, null, 2)); return; }
|
|
230
|
+
const d = await call('POST', '/api/v1/estimate', body);
|
|
231
|
+
out(d, 'Estimate: up to ~' + d.estimate + ' credits (balance: ' + d.balance + ')\n' + dim(d.note || ''));
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
async create() {
|
|
235
|
+
const body = buildBody();
|
|
236
|
+
if (!body.tool) die('--tool is required. See: facelessad tools', 'missing_tool');
|
|
237
|
+
if (flags['dry-run'] === true) { out({ ok: true, body }, JSON.stringify(body, null, 2)); return; }
|
|
238
|
+
const d = await call('POST', '/api/v1/videos', body);
|
|
239
|
+
if (!(flags.wait === true)) {
|
|
240
|
+
out(d, 'Queued. id=' + d.id + ' (estimate: up to ~' + d.estimate + ' credits)\nFollow it: facelessad status ' + d.id + ' --wait');
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
await waitFor(d.id);
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async status() {
|
|
247
|
+
const id = positional[0];
|
|
248
|
+
if (!id) die('Usage: facelessad status <id> [--wait]', 'missing_id');
|
|
249
|
+
if (flags.wait === true) { await waitFor(id); return; }
|
|
250
|
+
const d = await call('GET', '/api/v1/videos/' + encodeURIComponent(id));
|
|
251
|
+
out(d, statusLine(d));
|
|
252
|
+
},
|
|
253
|
+
|
|
254
|
+
async list() {
|
|
255
|
+
const q = new URLSearchParams();
|
|
256
|
+
if (flags.limit) q.set('limit', String(flags.limit));
|
|
257
|
+
if (flags.offset) q.set('offset', String(flags.offset));
|
|
258
|
+
const d = await call('GET', '/api/v1/videos' + (q.size ? '?' + q : ''));
|
|
259
|
+
out(d, (d.videos || []).map((v) => ' ' + String(v.id).padEnd(8) + v.status.padEnd(10) + (v.tool || '').padEnd(18) + dim(v.name || '')).join('\n') || ' (no videos yet)');
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async download() {
|
|
263
|
+
const id = positional[0];
|
|
264
|
+
if (!id) die('Usage: facelessad download <id> [--out file.mp4]', 'missing_id');
|
|
265
|
+
const d = await call('GET', '/api/v1/videos/' + encodeURIComponent(id));
|
|
266
|
+
if (!d.url) die('Video ' + id + ' has no downloadable render yet (status: ' + d.status + ')', 'not_ready');
|
|
267
|
+
const file = String(flags.out || ('facelessad-' + id + '.mp4'));
|
|
268
|
+
const res = await fetch(d.url);
|
|
269
|
+
if (!res.ok) die('Download failed: HTTP ' + res.status, 'download_failed');
|
|
270
|
+
fs.writeFileSync(file, Buffer.from(await res.arrayBuffer()));
|
|
271
|
+
out({ ok: true, file }, 'Saved ' + file);
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
async help() {
|
|
275
|
+
process.stdout.write(`${bold('facelessad')} — faceless video ads from the terminal
|
|
276
|
+
|
|
277
|
+
Commands:
|
|
278
|
+
login Save and verify your API key (~/.facelessad, 0600)
|
|
279
|
+
balance Plan and credit balance
|
|
280
|
+
tools [--tool <id>] Tool registry — styles, structures, durations
|
|
281
|
+
voices [--language --gender]Curated voice pool
|
|
282
|
+
estimate <create-flags> Upper-bound credit cost, without creating
|
|
283
|
+
create --tool <id> ... Create a video (returns an id immediately)
|
|
284
|
+
status <id> [--wait] Status; --wait polls until done
|
|
285
|
+
list [--limit --offset] Your videos, newest first
|
|
286
|
+
download <id> [--out file] Save the finished mp4
|
|
287
|
+
|
|
288
|
+
Create flags:
|
|
289
|
+
--tool --url --text --duration --aspect --language --style --style-hint
|
|
290
|
+
--structure --hook --video-mode --brand-color --brand-name --cta --name
|
|
291
|
+
--voice --gender --no-voice --music --sfx --captions --product-image
|
|
292
|
+
--file body.json (base body; flags override) --dry-run (print, don't send)
|
|
293
|
+
|
|
294
|
+
Every command accepts --json. FACELESSAD_API_KEY wins over the saved key
|
|
295
|
+
(use it in CI); long builds are better served by a webhook than --wait —
|
|
296
|
+
see ${API}/developers.
|
|
297
|
+
`);
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
function statusLine(d) {
|
|
302
|
+
let s = 'Video ' + d.id + ': ' + bold(d.status);
|
|
303
|
+
if (d.phase) s += ' ' + dim('(' + d.phase + ')');
|
|
304
|
+
if (d.url) s += '\nURL (valid ~1 h): ' + d.url;
|
|
305
|
+
if (d.error) s += '\n' + d.error;
|
|
306
|
+
return s;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function waitFor(id) {
|
|
310
|
+
const started = Date.now();
|
|
311
|
+
let last = '';
|
|
312
|
+
for (;;) {
|
|
313
|
+
const d = await call('GET', '/api/v1/videos/' + encodeURIComponent(id));
|
|
314
|
+
const mark = d.status + '|' + (d.phase || '');
|
|
315
|
+
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
316
|
+
if (!asJson && mark !== last) {
|
|
317
|
+
process.stderr.write('[' + elapsed + 's] ' + d.status + (d.phase ? ' — ' + d.phase : '') + '\n');
|
|
318
|
+
last = mark;
|
|
319
|
+
}
|
|
320
|
+
if (d.status === 'done') { out(d, statusLine(d)); return; }
|
|
321
|
+
if (d.status === 'failed') {
|
|
322
|
+
if (asJson) { process.stdout.write(JSON.stringify(d) + '\n'); }
|
|
323
|
+
else { process.stderr.write('Error: video ' + id + ' failed' + (d.error ? ': ' + d.error : '') + '\n'); }
|
|
324
|
+
process.exit(1);
|
|
325
|
+
}
|
|
326
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ────────────────────────── run ──────────────────────────
|
|
331
|
+
|
|
332
|
+
const fn = commands[cmd];
|
|
333
|
+
if (!fn) die('Unknown command "' + cmd + '". Run: facelessad help', 'unknown_command');
|
|
334
|
+
fn().catch((e) => die(e.message || String(e), 'unexpected'));
|
package/package.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@facelessad/cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create faceless video ads from your terminal or build scripts — the FacelessAd command line.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "facelessad": "index.js" },
|
|
8
|
+
"files": ["index.js", "README.md"],
|
|
9
|
+
"engines": { "node": ">=18" },
|
|
10
|
+
"keywords": ["facelessad", "video", "ads", "ai", "cli"],
|
|
11
|
+
"homepage": "https://facelessad.com/developers"
|
|
12
|
+
}
|