faceless-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/src/index.mjs ADDED
@@ -0,0 +1,767 @@
1
+ import fs from "node:fs";
2
+ import readline from "node:readline/promises";
3
+ import { Command, Option } from "commander";
4
+ import { CONFIG_PATH, loadConfig, resolveApiKey, resolveBaseUrl, saveConfig } from "./config.mjs";
5
+ import { CliError, exitCodeFor, request, sleep } from "./client.mjs";
6
+ import { print, printError } from "./output.mjs";
7
+
8
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
9
+ let spec;
10
+ try {
11
+ spec = JSON.parse(
12
+ fs.readFileSync(new URL("./generated/operations.json", import.meta.url), "utf8")
13
+ );
14
+ } catch {
15
+ process.stderr.write(
16
+ 'cli/src/generated/operations.json is missing. Run "npm run generate:agents" in the repo root to generate it.\n'
17
+ );
18
+ process.exit(1);
19
+ }
20
+ const operations = spec.operations;
21
+
22
+ function getOp(operationId) {
23
+ const op = operations.find((o) => o.operationId === operationId);
24
+ if (!op) {
25
+ throw new CliError("internal_error", `Unknown operation: ${operationId}`);
26
+ }
27
+ return op;
28
+ }
29
+
30
+ function toInt(value) {
31
+ const n = parseInt(value, 10);
32
+ if (Number.isNaN(n)) throw new CliError("invalid_input", `Not a number: ${value}`);
33
+ return n;
34
+ }
35
+
36
+ function splitList(value) {
37
+ return value
38
+ .split(",")
39
+ .map((v) => v.trim())
40
+ .filter(Boolean);
41
+ }
42
+
43
+ function compact(obj) {
44
+ const out = {};
45
+ for (const [key, value] of Object.entries(obj)) {
46
+ if (value !== undefined && value !== null) out[key] = value;
47
+ }
48
+ return out;
49
+ }
50
+
51
+ const MODELS = ["storyboard", "motion_lite", "motion_pro"];
52
+ const PLATFORMS = ["youtube", "tiktok", "instagram", "x", "facebook", "linkedin", "threads"];
53
+ const OPTION_KINDS = [
54
+ "sources",
55
+ "styles",
56
+ "niches",
57
+ "languages",
58
+ "durations",
59
+ "models",
60
+ "captionThemes",
61
+ "music",
62
+ "backgrounds",
63
+ "subreddits",
64
+ ];
65
+
66
+ async function execute(
67
+ operationId,
68
+ { pathParams = {}, query = {}, body, idempotencyKey } = {},
69
+ cmd
70
+ ) {
71
+ const globals = cmd.optsWithGlobals();
72
+ const op = getOp(operationId);
73
+ const path = op.path.replace(/\{(\w+)\}/g, (_, name) => {
74
+ const value = pathParams[name];
75
+ if (value === undefined) {
76
+ throw new CliError("invalid_input", `Missing required parameter: ${name}`);
77
+ }
78
+ return encodeURIComponent(String(value));
79
+ });
80
+ return request({
81
+ method: op.method,
82
+ path,
83
+ query: compact(query),
84
+ body: body === undefined ? undefined : compact(body),
85
+ apiKey: resolveApiKey(globals),
86
+ baseUrl: resolveBaseUrl(globals),
87
+ idempotencyKey,
88
+ });
89
+ }
90
+
91
+ async function waitForTerminal({ operationId, label, id, timeoutSec, cmd }) {
92
+ const terminal = getOp(operationId).terminalStates || [];
93
+ const deadline = Date.now() + timeoutSec * 1000;
94
+ for (;;) {
95
+ const result = await execute(operationId, { pathParams: { id } }, cmd);
96
+ const data = (result && result.data) || {};
97
+ if (terminal.includes(data.status)) {
98
+ return result;
99
+ }
100
+ if (Date.now() >= deadline) {
101
+ throw new CliError(
102
+ "timeout",
103
+ `Timed out after ${timeoutSec}s waiting for ${label} ${id} (last status: ${data.status || "unknown"})`
104
+ );
105
+ }
106
+ await sleep(5000);
107
+ }
108
+ }
109
+
110
+ // Polls GET /videos/{id}/status until completed or failed.
111
+ function waitForVideo(id, timeoutSec, cmd) {
112
+ return waitForTerminal({ operationId: "getVideoStatus", label: "video", id, timeoutSec, cmd });
113
+ }
114
+
115
+ // Polls GET /renders/{id} until done or error.
116
+ function waitForRender(id, timeoutSec, cmd) {
117
+ return waitForTerminal({ operationId: "getRender", label: "render", id, timeoutSec, cmd });
118
+ }
119
+
120
+ function noteCost(operationId) {
121
+ const cost = getOp(operationId).creditCost;
122
+ if (cost && cost !== "none") process.stderr.write(`This costs ${cost}.\n`);
123
+ }
124
+
125
+ // Async create commands share one shape: state the cost, fire the request,
126
+ // then optionally poll the created video's status until it is terminal.
127
+ async function createAndMaybeWait(operationId, body, opts, cmd) {
128
+ const globals = cmd.optsWithGlobals();
129
+ noteCost(operationId);
130
+ const created = await execute(operationId, { body, idempotencyKey: opts.idempotencyKey }, cmd);
131
+ const id = created?.data?.id;
132
+ if (!opts.wait || !id) {
133
+ print(created, { json: globals.json });
134
+ return;
135
+ }
136
+ print(await waitForVideo(id, opts.timeout, cmd), { json: globals.json });
137
+ }
138
+
139
+ function withWaitFlags(command, what) {
140
+ return command
141
+ .option("--wait", `poll until the ${what} reaches a terminal state`)
142
+ .option("--timeout <sec>", "max seconds to wait with --wait", toInt, 600);
143
+ }
144
+
145
+ // Series settings shared by "series create" and "series update". Field names
146
+ // mirror the createSeries request schema.
147
+ function withSeriesFlags(command) {
148
+ return command
149
+ .option("--niche <niche>", "content niche, e.g. scary stories (see: faceless options --kind niches)")
150
+ .option("--custom-prompt <text>", "custom topic prompt used instead of (or alongside) a niche")
151
+ .option("--voice <id>", "TTS voice id for narration (see: faceless voices)")
152
+ .option("--style <style>", "visual style for generated imagery (see: faceless options --kind styles)")
153
+ .option("--language <language>", "video language, e.g. English")
154
+ .addOption(
155
+ new Option("--duration <sec>", "target episode length in seconds").choices(["30", "60", "90"])
156
+ )
157
+ .option("--destination <platform>", "primary auto-post destination, e.g. youtube or tiktok")
158
+ .option(
159
+ "--destination-accounts <id[,id...]>",
160
+ "connected account ids to auto-post to (see: faceless accounts)"
161
+ )
162
+ .option("--auto-post-time <HH:mm>", "daily auto-post time in the series timezone")
163
+ .option(
164
+ "--posting-days <days>",
165
+ "comma-separated days of the week to post, e.g. Monday,Wednesday (omit for every day)"
166
+ )
167
+ .option("--timezone <tz>", "IANA timezone for scheduling, e.g. America/New_York")
168
+ .option("--caption-style <name>", "caption theme name (see: faceless options --kind captionThemes)")
169
+ .option("--subreddit <name>", 'subreddit to pull posts from when source is "Reddit post"')
170
+ .option(
171
+ "--background-video <id>",
172
+ "background gameplay/footage id (see: faceless options --kind backgrounds)"
173
+ )
174
+ .option("--random-background-video", "pick a random background video per episode")
175
+ .option("--layout <layout>", "video layout variant")
176
+ .addOption(
177
+ new Option("--broll-model <model>", "generation model for visuals").choices(MODELS)
178
+ )
179
+ .option("--show-emojis", "overlay emojis on captions")
180
+ .option("--enable-background-music", "mix background music under the narration")
181
+ .option(
182
+ "--background-music-mood <mood>",
183
+ "background music mood (see: faceless options --kind music)"
184
+ )
185
+ .option("--hashtags <text>", "hashtags appended to post captions")
186
+ .option("--tone <tone>", "writing tone for generated scripts")
187
+ .addOption(
188
+ new Option("--youtube-privacy <p>", "privacy for auto-posted YouTube videos").choices([
189
+ "public",
190
+ "unlisted",
191
+ "private",
192
+ ])
193
+ );
194
+ }
195
+
196
+ function seriesBody(opts) {
197
+ return compact({
198
+ name: opts.name,
199
+ source: opts.source,
200
+ niche: opts.niche,
201
+ customPrompt: opts.customPrompt,
202
+ voice: opts.voice,
203
+ style: opts.style,
204
+ language: opts.language,
205
+ duration: opts.duration,
206
+ destination: opts.destination,
207
+ destinationAccounts: opts.destinationAccounts ? splitList(opts.destinationAccounts) : undefined,
208
+ autoPostTime: opts.autoPostTime,
209
+ postingDays: opts.postingDays ? splitList(opts.postingDays) : undefined,
210
+ timezone: opts.timezone,
211
+ captionStyle: opts.captionStyle,
212
+ subreddit: opts.subreddit,
213
+ backgroundVideo: opts.backgroundVideo,
214
+ useRandomBackgroundVideo: opts.randomBackgroundVideo,
215
+ layout: opts.layout,
216
+ brollModel: opts.brollModel,
217
+ showEmojis: opts.showEmojis,
218
+ enableBackgroundMusic: opts.enableBackgroundMusic,
219
+ backgroundMusicMood: opts.backgroundMusicMood,
220
+ hashtags: opts.hashtags,
221
+ tone: opts.tone,
222
+ youtubePrivacyStatus: opts.youtubePrivacy,
223
+ paused: opts.paused,
224
+ });
225
+ }
226
+
227
+ const program = new Command();
228
+
229
+ program
230
+ .name("faceless")
231
+ .description(
232
+ "Faceless.so CLI: create AI faceless videos, run automated series and publish to YouTube, TikTok, Instagram and more."
233
+ )
234
+ .version(pkg.version)
235
+ .option("--json", "output raw JSON")
236
+ .option("--api-key <key>", "API key (overrides env and config file)")
237
+ .option("--api-url <url>", "API base URL (default https://faceless.so/api/v1)");
238
+
239
+ program
240
+ .command("login")
241
+ .description("Save an API key to ~/.faceless/config.json (verified against /me)")
242
+ .action(async (_opts, cmd) => {
243
+ const globals = cmd.optsWithGlobals();
244
+ let key = globals.apiKey;
245
+ if (!key) {
246
+ const rl = readline.createInterface({
247
+ input: process.stdin,
248
+ output: process.stderr,
249
+ });
250
+ key = (await rl.question("API key (fl_live_...): ")).trim();
251
+ rl.close();
252
+ }
253
+ if (!key) throw new CliError("invalid_input", "No API key provided");
254
+ const result = await request({
255
+ method: "get",
256
+ path: "/me",
257
+ apiKey: key,
258
+ baseUrl: resolveBaseUrl(globals),
259
+ });
260
+ saveConfig({ apiKey: key });
261
+ if (globals.json || !process.stdout.isTTY) {
262
+ print(result, { json: true });
263
+ return;
264
+ }
265
+ const data = (result && result.data) || {};
266
+ const scopes = data.auth?.scopes || [];
267
+ process.stdout.write(`Logged in as ${data.team?.name || "unknown team"}\n`);
268
+ process.stdout.write(`Scopes: ${scopes.length ? scopes.join(", ") : "(none)"}\n`);
269
+ process.stdout.write(`Saved key to ${CONFIG_PATH}\n`);
270
+ });
271
+
272
+ program
273
+ .command("logout")
274
+ .description("Remove the saved API key from ~/.faceless/config.json")
275
+ .action(async () => {
276
+ const config = loadConfig();
277
+ if (!config.apiKey) {
278
+ process.stdout.write("No saved API key.\n");
279
+ return;
280
+ }
281
+ delete config.apiKey;
282
+ saveConfig({ ...config, apiKey: undefined });
283
+ process.stdout.write(`Removed API key from ${CONFIG_PATH}\n`);
284
+ });
285
+
286
+ program
287
+ .command("whoami")
288
+ .description(getOp("getMe").summary)
289
+ .action(async (_opts, cmd) => {
290
+ const result = await execute("getMe", {}, cmd);
291
+ print(result, { json: cmd.optsWithGlobals().json });
292
+ });
293
+
294
+ program
295
+ .command("credits")
296
+ .description(getOp("getCredits").summary)
297
+ .option("--page <n>", "page number", toInt)
298
+ .option("--limit <n>", "items per page (max 100)", toInt)
299
+ .action(async (opts, cmd) => {
300
+ const result = await execute("getCredits", { query: { page: opts.page, limit: opts.limit } }, cmd);
301
+ print(result, { json: cmd.optsWithGlobals().json });
302
+ });
303
+
304
+ const videos = program.command("videos").description("Faceless video projects");
305
+
306
+ withWaitFlags(
307
+ videos
308
+ .command("create")
309
+ .description(getOp("createVideo").summary)
310
+ .requiredOption("--script <text>", "the full narration script the video is generated from")
311
+ .requiredOption("--voice-id <id>", "TTS voice id for the narration (see: faceless voices)")
312
+ .addOption(
313
+ new Option(
314
+ "--model <model>",
315
+ "generation model: storyboard (20 credits), motion_lite (50) or motion_pro (100)"
316
+ ).choices(MODELS)
317
+ )
318
+ .option("--style <style>", "visual style for generated imagery (see: faceless options --kind styles)")
319
+ .option("--language <language>", "script language, e.g. English")
320
+ .option("--name <name>", "project name; defaults to the start of the script")
321
+ .option("--enable-background-music", "mix background music under the narration")
322
+ .option("--master-style <text>", "extra style directive applied to every generated scene")
323
+ .option("--global-negative-prompt <text>", "things the image model should avoid in every scene")
324
+ .option("--idempotency-key <k>", "Idempotency-Key header value"),
325
+ "video"
326
+ ).action(async (opts, cmd) => {
327
+ await createAndMaybeWait(
328
+ "createVideo",
329
+ {
330
+ script: opts.script,
331
+ voiceId: opts.voiceId,
332
+ model: opts.model,
333
+ style: opts.style,
334
+ language: opts.language,
335
+ name: opts.name,
336
+ enableBackgroundMusic: opts.enableBackgroundMusic,
337
+ masterStyle: opts.masterStyle,
338
+ globalNegativePrompt: opts.globalNegativePrompt,
339
+ },
340
+ opts,
341
+ cmd
342
+ );
343
+ });
344
+
345
+ withWaitFlags(
346
+ videos
347
+ .command("captions")
348
+ .description(getOp("createCaptionVideo").summary)
349
+ .option("--video-url <url>", "public URL of the video file to caption")
350
+ .option("--audio-url <url>", "public URL of an audio file to turn into a captioned video")
351
+ .option("--name <name>", "project name")
352
+ .option("--language <language>", "spoken language of the file, e.g. English")
353
+ .option("--idempotency-key <k>", "Idempotency-Key header value"),
354
+ "video"
355
+ ).action(async (opts, cmd) => {
356
+ await createAndMaybeWait(
357
+ "createCaptionVideo",
358
+ {
359
+ videoUrl: opts.videoUrl,
360
+ audioUrl: opts.audioUrl,
361
+ name: opts.name,
362
+ language: opts.language,
363
+ },
364
+ opts,
365
+ cmd
366
+ );
367
+ });
368
+
369
+ videos
370
+ .command("list")
371
+ .description(getOp("listVideos").summary)
372
+ .option("--archived", "only archived videos")
373
+ .option("--page <n>", "page number", toInt)
374
+ .option("--limit <n>", "items per page (max 100)", toInt)
375
+ .action(async (opts, cmd) => {
376
+ const result = await execute(
377
+ "listVideos",
378
+ { query: { archived: opts.archived, page: opts.page, limit: opts.limit } },
379
+ cmd
380
+ );
381
+ print(result, { json: cmd.optsWithGlobals().json });
382
+ });
383
+
384
+ videos
385
+ .command("get <id>")
386
+ .description(getOp("getVideo").summary)
387
+ .action(async (id, _opts, cmd) => {
388
+ const result = await execute("getVideo", { pathParams: { id } }, cmd);
389
+ print(result, { json: cmd.optsWithGlobals().json });
390
+ });
391
+
392
+ videos
393
+ .command("update <id>")
394
+ .description(getOp("updateVideo").summary)
395
+ .option("--name <name>", "project name")
396
+ .option("--youtube-title <text>", "YouTube title (max 100 chars)")
397
+ .option("--youtube-description <text>", "YouTube description")
398
+ .addOption(
399
+ new Option("--youtube-privacy <p>", "YouTube privacy, defaults to public").choices([
400
+ "public",
401
+ "unlisted",
402
+ "private",
403
+ ])
404
+ )
405
+ .option("--tiktok-title <text>", "TikTok caption")
406
+ .option("--instagram-caption <text>", "Instagram caption")
407
+ .option("--x-text <text>", "X post text (max 280 chars)")
408
+ .option("--facebook-description <text>", "Facebook description")
409
+ .option("--linkedin-description <text>", "LinkedIn description")
410
+ .option("--threads-text <text>", "Threads post text")
411
+ .action(async (id, opts, cmd) => {
412
+ const youtubePost = compact({
413
+ title: opts.youtubeTitle,
414
+ description: opts.youtubeDescription,
415
+ privacyStatus: opts.youtubePrivacy,
416
+ });
417
+ const body = compact({
418
+ name: opts.name,
419
+ youtubePost: Object.keys(youtubePost).length ? youtubePost : undefined,
420
+ tiktokPost: opts.tiktokTitle !== undefined ? { title: opts.tiktokTitle } : undefined,
421
+ instagramPost:
422
+ opts.instagramCaption !== undefined ? { caption: opts.instagramCaption } : undefined,
423
+ xPost: opts.xText !== undefined ? { text: opts.xText } : undefined,
424
+ facebookPost:
425
+ opts.facebookDescription !== undefined
426
+ ? { description: opts.facebookDescription }
427
+ : undefined,
428
+ linkedinPost:
429
+ opts.linkedinDescription !== undefined
430
+ ? { description: opts.linkedinDescription }
431
+ : undefined,
432
+ threadsPost: opts.threadsText !== undefined ? { text: opts.threadsText } : undefined,
433
+ });
434
+ const result = await execute("updateVideo", { pathParams: { id }, body }, cmd);
435
+ print(result, { json: cmd.optsWithGlobals().json });
436
+ });
437
+
438
+ withWaitFlags(
439
+ videos.command("status <id>").description(getOp("getVideoStatus").summary),
440
+ "video"
441
+ ).action(async (id, opts, cmd) => {
442
+ const globals = cmd.optsWithGlobals();
443
+ const result = opts.wait
444
+ ? await waitForVideo(id, opts.timeout, cmd)
445
+ : await execute("getVideoStatus", { pathParams: { id } }, cmd);
446
+ print(result, { json: globals.json });
447
+ });
448
+
449
+ withWaitFlags(
450
+ videos
451
+ .command("render <id>")
452
+ .description(getOp("renderVideo").summary)
453
+ .addOption(new Option("--codec <codec>", "output codec; h264 for MP4").choices(["h264", "vp8"]))
454
+ .option("--idempotency-key <k>", "Idempotency-Key header value"),
455
+ "render"
456
+ ).action(async (id, opts, cmd) => {
457
+ const globals = cmd.optsWithGlobals();
458
+ const started = await execute(
459
+ "renderVideo",
460
+ { pathParams: { id }, body: { codec: opts.codec }, idempotencyKey: opts.idempotencyKey },
461
+ cmd
462
+ );
463
+ const renderId = started?.data?.renderId;
464
+ if (!opts.wait || !renderId) {
465
+ print(started, { json: globals.json });
466
+ return;
467
+ }
468
+ print(await waitForRender(renderId, opts.timeout, cmd), { json: globals.json });
469
+ });
470
+
471
+ videos
472
+ .command("delete <id>")
473
+ .description(getOp("deleteVideo").summary)
474
+ .action(async (id, _opts, cmd) => {
475
+ const result = await execute("deleteVideo", { pathParams: { id } }, cmd);
476
+ print(result, { json: cmd.optsWithGlobals().json });
477
+ });
478
+
479
+ const renders = program.command("renders").description("Render progress");
480
+
481
+ withWaitFlags(renders.command("get <id>").description(getOp("getRender").summary), "render").action(
482
+ async (id, opts, cmd) => {
483
+ const globals = cmd.optsWithGlobals();
484
+ const result = opts.wait
485
+ ? await waitForRender(id, opts.timeout, cmd)
486
+ : await execute("getRender", { pathParams: { id } }, cmd);
487
+ print(result, { json: globals.json });
488
+ }
489
+ );
490
+
491
+ const series = program.command("series").description("Automated video series");
492
+
493
+ withSeriesFlags(
494
+ series
495
+ .command("create")
496
+ .description(getOp("createSeries").summary)
497
+ .requiredOption("--name <name>", "series name shown in the dashboard")
498
+ .requiredOption(
499
+ "--source <source>",
500
+ 'content source, e.g. "Facts & stories" (see: faceless options --kind sources)'
501
+ )
502
+ )
503
+ .option("--idempotency-key <k>", "Idempotency-Key header value")
504
+ .action(async (opts, cmd) => {
505
+ const result = await execute(
506
+ "createSeries",
507
+ { body: seriesBody(opts), idempotencyKey: opts.idempotencyKey },
508
+ cmd
509
+ );
510
+ print(result, { json: cmd.optsWithGlobals().json });
511
+ });
512
+
513
+ series
514
+ .command("list")
515
+ .description(getOp("listSeries").summary)
516
+ .action(async (_opts, cmd) => {
517
+ const result = await execute("listSeries", {}, cmd);
518
+ print(result, { json: cmd.optsWithGlobals().json });
519
+ });
520
+
521
+ series
522
+ .command("get <id>")
523
+ .description(getOp("getSeries").summary)
524
+ .action(async (id, _opts, cmd) => {
525
+ const result = await execute("getSeries", { pathParams: { id } }, cmd);
526
+ print(result, { json: cmd.optsWithGlobals().json });
527
+ });
528
+
529
+ withSeriesFlags(
530
+ series
531
+ .command("update <id>")
532
+ .description(getOp("updateSeries").summary)
533
+ .option("--name <name>", "series name")
534
+ .option("--source <source>", "content source (see: faceless options --kind sources)")
535
+ .option("--paused", "pause automatic episode generation")
536
+ .option("--no-paused", "resume automatic episode generation")
537
+ ).action(async (id, opts, cmd) => {
538
+ const result = await execute("updateSeries", { pathParams: { id }, body: seriesBody(opts) }, cmd);
539
+ print(result, { json: cmd.optsWithGlobals().json });
540
+ });
541
+
542
+ series
543
+ .command("delete <id>")
544
+ .description(getOp("deleteSeries").summary)
545
+ .action(async (id, _opts, cmd) => {
546
+ const result = await execute("deleteSeries", { pathParams: { id } }, cmd);
547
+ print(result, { json: cmd.optsWithGlobals().json });
548
+ });
549
+
550
+ withWaitFlags(
551
+ series
552
+ .command("generate <id>")
553
+ .description(getOp("generateSeriesEpisode").summary)
554
+ .option("--idempotency-key <k>", "Idempotency-Key header value"),
555
+ "episode's video"
556
+ ).action(async (id, opts, cmd) => {
557
+ const globals = cmd.optsWithGlobals();
558
+ noteCost("generateSeriesEpisode");
559
+ const started = await execute(
560
+ "generateSeriesEpisode",
561
+ { pathParams: { id }, idempotencyKey: opts.idempotencyKey },
562
+ cmd
563
+ );
564
+ const projectId = started?.data?.projectId;
565
+ if (!opts.wait || !projectId) {
566
+ print(started, { json: globals.json });
567
+ return;
568
+ }
569
+ print(await waitForVideo(projectId, opts.timeout, cmd), { json: globals.json });
570
+ });
571
+
572
+ series
573
+ .command("episodes <id>")
574
+ .description(getOp("listSeriesEpisodes").summary)
575
+ .option("--page <n>", "page number", toInt)
576
+ .option("--limit <n>", "items per page (max 100)", toInt)
577
+ .action(async (id, opts, cmd) => {
578
+ const result = await execute(
579
+ "listSeriesEpisodes",
580
+ { pathParams: { id }, query: { page: opts.page, limit: opts.limit } },
581
+ cmd
582
+ );
583
+ print(result, { json: cmd.optsWithGlobals().json });
584
+ });
585
+
586
+ const posts = program.command("posts").description("Publishing and scheduling");
587
+
588
+ posts
589
+ .command("publish")
590
+ .description(getOp("publishPost").summary)
591
+ .requiredOption("--video-id <id>", "the video (project) to publish")
592
+ .addOption(
593
+ new Option("--platform <platform>", "destination platform; must be connected").choices(
594
+ PLATFORMS
595
+ ).makeOptionMandatory()
596
+ )
597
+ .option("--title <text>", "post title or caption; falls back to the video's stored post metadata")
598
+ .option("--description <text>", "longer description (YouTube, Facebook, LinkedIn)")
599
+ .addOption(
600
+ new Option("--privacy <p>", "YouTube only; defaults to public").choices([
601
+ "public",
602
+ "unlisted",
603
+ "private",
604
+ ])
605
+ )
606
+ .option("--auth-id <id>", "specific connected account id (see: faceless accounts)")
607
+ .option("--idempotency-key <k>", "Idempotency-Key header value")
608
+ .action(async (opts, cmd) => {
609
+ const result = await execute(
610
+ "publishPost",
611
+ {
612
+ body: {
613
+ videoId: opts.videoId,
614
+ platform: opts.platform,
615
+ title: opts.title,
616
+ description: opts.description,
617
+ privacyStatus: opts.privacy,
618
+ authId: opts.authId,
619
+ },
620
+ idempotencyKey: opts.idempotencyKey,
621
+ },
622
+ cmd
623
+ );
624
+ print(result, { json: cmd.optsWithGlobals().json });
625
+ });
626
+
627
+ posts
628
+ .command("schedule")
629
+ .description(getOp("schedulePost").summary)
630
+ .requiredOption("--video-id <id>", "the video (project) to schedule")
631
+ .requiredOption(
632
+ "--platforms <platform[,platform...]>",
633
+ "platforms to post to; each needs its post metadata set on the video first (faceless videos update)"
634
+ )
635
+ .requiredOption("--scheduled-time <ISO>", "when to post (ISO 8601, future)")
636
+ .option("--idempotency-key <k>", "Idempotency-Key header value")
637
+ .action(async (opts, cmd) => {
638
+ const result = await execute(
639
+ "schedulePost",
640
+ {
641
+ body: {
642
+ videoId: opts.videoId,
643
+ platforms: splitList(opts.platforms),
644
+ scheduledTime: opts.scheduledTime,
645
+ },
646
+ idempotencyKey: opts.idempotencyKey,
647
+ },
648
+ cmd
649
+ );
650
+ print(result, { json: cmd.optsWithGlobals().json });
651
+ });
652
+
653
+ posts
654
+ .command("cancel <videoId>")
655
+ .description(getOp("cancelPost").summary)
656
+ .action(async (videoId, _opts, cmd) => {
657
+ const result = await execute("cancelPost", { pathParams: { id: videoId } }, cmd);
658
+ print(result, { json: cmd.optsWithGlobals().json });
659
+ });
660
+
661
+ program
662
+ .command("calendar")
663
+ .description(getOp("getCalendar").summary)
664
+ .requiredOption("--start-date <date>", "range start (ISO 8601, inclusive)")
665
+ .requiredOption("--end-date <date>", "range end (ISO 8601, inclusive)")
666
+ .addOption(new Option("--platform <platform>", "filter to one platform").choices(PLATFORMS))
667
+ .option("--status <status>", "filter by post status, e.g. scheduled, posted, failed")
668
+ .action(async (opts, cmd) => {
669
+ const result = await execute(
670
+ "getCalendar",
671
+ {
672
+ query: {
673
+ startDate: opts.startDate,
674
+ endDate: opts.endDate,
675
+ platform: opts.platform,
676
+ status: opts.status,
677
+ },
678
+ },
679
+ cmd
680
+ );
681
+ print(result, { json: cmd.optsWithGlobals().json });
682
+ });
683
+
684
+ program
685
+ .command("accounts")
686
+ .description(getOp("listAccounts").summary)
687
+ .action(async (_opts, cmd) => {
688
+ const result = await execute("listAccounts", {}, cmd);
689
+ print(result, { json: cmd.optsWithGlobals().json });
690
+ });
691
+
692
+ program
693
+ .command("voices")
694
+ .description(getOp("listVoices").summary)
695
+ .action(async (_opts, cmd) => {
696
+ const result = await execute("listVoices", {}, cmd);
697
+ print(result, { json: cmd.optsWithGlobals().json });
698
+ });
699
+
700
+ program
701
+ .command("options")
702
+ .description(getOp("listOptions").summary)
703
+ .addOption(
704
+ new Option("--kind <kind>", "which catalog to return; omit to list available kinds").choices(
705
+ OPTION_KINDS
706
+ )
707
+ )
708
+ .action(async (opts, cmd) => {
709
+ const result = await execute("listOptions", { query: { kind: opts.kind } }, cmd);
710
+ print(result, { json: cmd.optsWithGlobals().json });
711
+ });
712
+
713
+ const assets = program.command("assets").description("Media asset library");
714
+
715
+ assets
716
+ .command("create")
717
+ .description(getOp("createAsset").summary)
718
+ .requiredOption("--url <url>", "public URL of the media file")
719
+ .option("--name <name>", "display name for the asset")
720
+ .option("--file-type <mime>", "MIME type hint, e.g. video/mp4")
721
+ .option("--idempotency-key <k>", "Idempotency-Key header value")
722
+ .action(async (opts, cmd) => {
723
+ const result = await execute(
724
+ "createAsset",
725
+ {
726
+ body: { url: opts.url, name: opts.name, fileType: opts.fileType },
727
+ idempotencyKey: opts.idempotencyKey,
728
+ },
729
+ cmd
730
+ );
731
+ print(result, { json: cmd.optsWithGlobals().json });
732
+ });
733
+
734
+ program
735
+ .command("analytics")
736
+ .description(getOp("getAnalytics").summary)
737
+ .addOption(
738
+ new Option("--platform <platform>", "filter to one platform").choices([
739
+ "youtube",
740
+ "tiktok",
741
+ "instagram",
742
+ "facebook",
743
+ ])
744
+ )
745
+ .option("--auth-id <id>", "filter to one connected account (see: faceless accounts)")
746
+ .option("--range <days>", "trailing window in days (default 30, max 365)", toInt)
747
+ .action(async (opts, cmd) => {
748
+ const result = await execute(
749
+ "getAnalytics",
750
+ { query: { platform: opts.platform, authId: opts.authId, range: opts.range } },
751
+ cmd
752
+ );
753
+ print(result, { json: cmd.optsWithGlobals().json });
754
+ });
755
+
756
+ program
757
+ .command("mcp")
758
+ .description("Start the local Faceless MCP server over stdio")
759
+ .action(async () => {
760
+ const { runMcpServer } = await import("./mcp/stdio.mjs");
761
+ await runMcpServer();
762
+ });
763
+
764
+ program.parseAsync(process.argv).catch((err) => {
765
+ printError(err);
766
+ process.exit(exitCodeFor(err));
767
+ });