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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faceless.so
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # Faceless CLI
2
+
3
+ Command line client for the [Faceless.so](https://faceless.so) public API: create AI faceless videos from a script, run automated series and publish to YouTube, TikTok, Instagram, X, Facebook, LinkedIn and Threads. Also ships a local MCP server so agents can use Faceless as tools.
4
+
5
+ API reference: https://faceless.so/api/v1/openapi.json
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g faceless-cli
11
+ ```
12
+
13
+ Requires Node 18 or newer. The package installs two equivalent commands: `faceless` and `fcl`.
14
+
15
+ ## Authentication
16
+
17
+ Create an API key in your team settings at https://faceless.so/team, then either log in once:
18
+
19
+ ```bash
20
+ faceless login
21
+ ```
22
+
23
+ This verifies the key against `GET /me` and saves it to `~/.faceless/config.json` (file mode 600). Or use an environment variable instead:
24
+
25
+ ```bash
26
+ export FACELESS_API_KEY=fl_live_...
27
+ faceless whoami
28
+ ```
29
+
30
+ Key precedence: `--api-key` flag, then `FACELESS_API_KEY`, then the config file. The base URL can be overridden with `--api-url` or `FACELESS_API_URL`. Use `faceless logout` to remove the saved key.
31
+
32
+ ## Commands
33
+
34
+ | Command | Description |
35
+ | ------------------------------ | --------------------------------------------------------------------- |
36
+ | `faceless login` | Save an API key to `~/.faceless/config.json` (verified against `/me`) |
37
+ | `faceless logout` | Remove the saved API key |
38
+ | `faceless whoami` | Identify the caller (team, plan, scopes) |
39
+ | `faceless credits` | Credit balance and history |
40
+ | `faceless videos create` | Create a faceless video from a script (charges credits) |
41
+ | `faceless videos captions` | Caption an existing video or audio URL |
42
+ | `faceless videos list` | List the team's videos |
43
+ | `faceless videos get <id>` | Get one video |
44
+ | `faceless videos update <id>` | Update a video's name or per-platform post metadata |
45
+ | `faceless videos status <id>` | Poll video generation progress (`--wait`) |
46
+ | `faceless videos render <id>` | Render a generated video to MP4 (`--wait`) |
47
+ | `faceless videos delete <id>` | Delete a video |
48
+ | `faceless renders get <id>` | Poll render progress (`--wait`) |
49
+ | `faceless series create` | Create an automated video series |
50
+ | `faceless series list` | List the team's series |
51
+ | `faceless series get <id>` | Get one series |
52
+ | `faceless series update <id>` | Update a series (`--paused` / `--no-paused` to pause or resume) |
53
+ | `faceless series delete <id>` | Delete a series |
54
+ | `faceless series generate <id>`| Generate the next episode now (`--wait`) |
55
+ | `faceless series episodes <id>`| List a series' episodes |
56
+ | `faceless posts publish` | Publish a rendered video to a platform now |
57
+ | `faceless posts schedule` | Schedule a video to one or more platforms |
58
+ | `faceless posts cancel <videoId>` | Cancel a scheduled post |
59
+ | `faceless calendar` | Posting calendar for a date range |
60
+ | `faceless accounts` | List connected social accounts |
61
+ | `faceless voices` | List TTS voices |
62
+ | `faceless options` | List option catalogs (styles, niches, models, ...) |
63
+ | `faceless assets create` | Register a media asset by URL |
64
+ | `faceless analytics` | Cross-platform posting analytics |
65
+ | `faceless mcp` | Start the local MCP server over stdio |
66
+
67
+ Global options: `--json`, `--api-key <key>`, `--api-url <url>`. Run any command with `--help` for its flags.
68
+
69
+ ## Quickstart: create, render, publish
70
+
71
+ ```bash
72
+ # Pick a voice and see what everything costs
73
+ faceless voices
74
+ faceless options --kind models
75
+
76
+ # 1. Create a video from a script and wait for generation to finish
77
+ # (storyboard 20 credits, motion_lite 50, motion_pro 100)
78
+ faceless videos create \
79
+ --script "Did you know the ocean has lakes and rivers of its own? ..." \
80
+ --voice-id EXAVITQu4vr4xnSDxMaL \
81
+ --model storyboard --wait
82
+
83
+ # 2. Render it to MP4 and wait for the download URL
84
+ faceless videos render 665f1b2a9c31a2b3c4d5e801 --wait
85
+
86
+ # 3a. Publish it to YouTube right now
87
+ faceless accounts
88
+ faceless posts publish --video-id 665f1b2a9c31a2b3c4d5e801 \
89
+ --platform youtube --title "The ocean has rivers underwater"
90
+
91
+ # 3b. Or set per-platform metadata and schedule it for later
92
+ faceless videos update 665f1b2a9c31a2b3c4d5e801 \
93
+ --youtube-title "The ocean has rivers underwater" \
94
+ --tiktok-title "underwater rivers are real #ocean"
95
+ faceless posts schedule --video-id 665f1b2a9c31a2b3c4d5e801 \
96
+ --platforms youtube,tiktok --scheduled-time 2026-08-01T18:00:00Z
97
+
98
+ # What is queued this week?
99
+ faceless calendar --start-date 2026-08-01 --end-date 2026-08-07
100
+ ```
101
+
102
+ More flows:
103
+
104
+ ```bash
105
+ # Caption footage you already have
106
+ faceless videos captions --video-url https://example.com/clip.mp4 --wait
107
+
108
+ # A set-and-forget channel: one episode per day at 6pm
109
+ faceless series create --name "Deep sea facts" \
110
+ --source "Facts & stories" --niche "Ocean facts" \
111
+ --voice EXAVITQu4vr4xnSDxMaL --duration 60 \
112
+ --auto-post-time 18:00 --timezone America/New_York
113
+ faceless series generate 665f1b2a9c31a2b3c4d5e901 --wait
114
+ faceless series episodes 665f1b2a9c31a2b3c4d5e901
115
+ ```
116
+
117
+ ## JSON output and exit codes
118
+
119
+ Pass `--json` to print the raw API envelope (`{ "success": true, "data": ... }`). When stdout is not a TTY (pipes, scripts, CI), JSON is printed automatically, so `faceless videos list | jq` just works.
120
+
121
+ Errors are written to stderr as `error (<type>): <message>` with these exit codes:
122
+
123
+ | Exit code | Meaning |
124
+ | --------- | ---------------------------------------------------------------------- |
125
+ | `0` | Success |
126
+ | `1` | Any other error (invalid input, not found, conflict, network, timeout) |
127
+ | `2` | `unauthorized` or `forbidden_scope` |
128
+ | `3` | `insufficient_credits` |
129
+ | `4` | `rate_limited` |
130
+
131
+ On HTTP 429 the client retries once after the `Retry-After` interval (capped at 30 seconds). Every mutating request carries an auto-generated `Idempotency-Key` (override with `--idempotency-key`), so retries can never double-charge credits.
132
+
133
+ ## Waiting for async work
134
+
135
+ Generation and rendering are asynchronous. `--wait` (with `--timeout <sec>`, default 600) polls every 5 seconds until a terminal state:
136
+
137
+ - `videos create --wait` and `videos captions --wait` poll `videos status` until the video is `completed` or `failed`
138
+ - `videos render <id> --wait` polls `renders get` with the returned renderId until `done` or `error`
139
+ - `series generate <id> --wait` polls the created episode's video status
140
+ - `videos status <id> --wait` and `renders get <id> --wait` do the same for something already in flight
141
+
142
+ `posts publish` is synchronous and returns the post URL directly.
143
+
144
+ ## MCP server
145
+
146
+ The CLI doubles as a local MCP server over stdio, exposing the Faceless API as tools (`faceless_create_video`, `faceless_render_video`, `faceless_publish_post`, and more). Add it to Claude Code:
147
+
148
+ ```bash
149
+ claude mcp add faceless -- npx -y faceless-cli mcp
150
+ ```
151
+
152
+ Make sure `FACELESS_API_KEY` is set in the environment (or run `faceless login` first).
153
+
154
+ Prefer a hosted option? Faceless also serves a remote MCP endpoint at `https://faceless.so/api/v1/mcp` (Authorization: `Bearer fl_live_...`), no local install needed.
155
+
156
+ ## Links
157
+
158
+ - Faceless.so: https://faceless.so
159
+ - API keys: https://faceless.so/team
160
+ - OpenAPI spec: https://faceless.so/api/v1/openapi.json
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../src/index.mjs";
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "faceless-cli",
3
+ "version": "1.0.0",
4
+ "description": "CLI for Faceless.so: create AI faceless videos, run automated series and publish to YouTube, TikTok, Instagram and more.",
5
+ "type": "module",
6
+ "bin": {
7
+ "faceless": "bin/faceless.js",
8
+ "fcl": "bin/faceless.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "license": "MIT",
20
+ "homepage": "https://faceless.so",
21
+ "keywords": [
22
+ "faceless",
23
+ "cli",
24
+ "mcp",
25
+ "ai",
26
+ "video",
27
+ "faceless-video",
28
+ "youtube",
29
+ "tiktok",
30
+ "social-media",
31
+ "scheduling"
32
+ ],
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.29.0",
35
+ "commander": "^13.0.0"
36
+ }
37
+ }
package/src/client.mjs ADDED
@@ -0,0 +1,129 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ export class CliError extends Error {
4
+ constructor(type, message, status) {
5
+ super(message);
6
+ this.name = "CliError";
7
+ this.type = type || "internal_error";
8
+ this.status = status;
9
+ }
10
+ }
11
+
12
+ export function exitCodeFor(err) {
13
+ const type = err && err.type;
14
+ if (type === "unauthorized" || type === "forbidden_scope") return 2;
15
+ if (type === "insufficient_credits") return 3;
16
+ if (type === "rate_limited") return 4;
17
+ return 1;
18
+ }
19
+
20
+ function typeForStatus(status) {
21
+ switch (status) {
22
+ case 400:
23
+ return "invalid_input";
24
+ case 401:
25
+ return "unauthorized";
26
+ case 402:
27
+ return "insufficient_credits";
28
+ case 403:
29
+ return "forbidden_scope";
30
+ case 404:
31
+ return "not_found";
32
+ case 409:
33
+ return "conflict";
34
+ case 429:
35
+ return "rate_limited";
36
+ default:
37
+ return "internal_error";
38
+ }
39
+ }
40
+
41
+ function buildUrl(baseUrl, path, query) {
42
+ const url = new URL(baseUrl + path);
43
+ if (query) {
44
+ for (const [key, value] of Object.entries(query)) {
45
+ if (value === undefined || value === null || value === "") continue;
46
+ url.searchParams.set(key, String(value));
47
+ }
48
+ }
49
+ return url;
50
+ }
51
+
52
+ export function sleep(ms) {
53
+ return new Promise((resolve) => setTimeout(resolve, ms));
54
+ }
55
+
56
+ function retryAfterMs(res) {
57
+ const header = res.headers.get("retry-after");
58
+ let seconds = Number(header);
59
+ if (!Number.isFinite(seconds) || seconds < 0) seconds = 1;
60
+ return Math.min(seconds, 30) * 1000;
61
+ }
62
+
63
+ export async function request({ method, path, query, body, apiKey, baseUrl, idempotencyKey }) {
64
+ if (!apiKey) {
65
+ throw new CliError(
66
+ "unauthorized",
67
+ 'No API key configured. Run "faceless login" or set FACELESS_API_KEY.'
68
+ );
69
+ }
70
+
71
+ const url = buildUrl(baseUrl, path, query);
72
+ const headers = { Authorization: `Bearer ${apiKey}` };
73
+ const httpMethod = method.toUpperCase();
74
+ const init = { method: httpMethod, headers };
75
+ if (body !== undefined && body !== null) {
76
+ headers["Content-Type"] = "application/json";
77
+ init.body = JSON.stringify(body);
78
+ }
79
+ // Every mutating request carries an Idempotency-Key so a retry can never be
80
+ // applied (or charge credits) twice. Ops without server-side idempotency
81
+ // simply ignore the header. A user-provided --idempotency-key wins.
82
+ const isMutating = httpMethod !== "GET" && httpMethod !== "HEAD";
83
+ if (isMutating && !idempotencyKey) idempotencyKey = randomUUID();
84
+ if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
85
+
86
+ let res;
87
+ try {
88
+ res = await fetch(url, init);
89
+ // Retry a rate-limited request once. Insufficient credits is 402 (never
90
+ // retried); mutating retries are safe because of the Idempotency-Key.
91
+ if (res.status === 429) {
92
+ await sleep(retryAfterMs(res));
93
+ res = await fetch(url, init);
94
+ }
95
+ } catch (err) {
96
+ throw new CliError("internal_error", `Network error calling ${url.origin}: ${err.message}`);
97
+ }
98
+
99
+ const text = await res.text();
100
+ let parsed = null;
101
+ if (text) {
102
+ try {
103
+ parsed = JSON.parse(text);
104
+ } catch {
105
+ throw new CliError(
106
+ typeForStatus(res.status),
107
+ `Unexpected non-JSON response (HTTP ${res.status})`,
108
+ res.status
109
+ );
110
+ }
111
+ }
112
+
113
+ if (parsed && parsed.success === false) {
114
+ const e = parsed.error || {};
115
+ throw new CliError(
116
+ e.type || typeForStatus(res.status),
117
+ e.message || `Request failed (HTTP ${res.status})`,
118
+ res.status
119
+ );
120
+ }
121
+ if (!res.ok) {
122
+ throw new CliError(
123
+ typeForStatus(res.status),
124
+ `Request failed (HTTP ${res.status})`,
125
+ res.status
126
+ );
127
+ }
128
+ return parsed;
129
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,47 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const DEFAULT_BASE_URL = "https://faceless.so/api/v1";
6
+ export const CONFIG_DIR = path.join(os.homedir(), ".faceless");
7
+ export const CONFIG_PATH = path.join(CONFIG_DIR, "config.json");
8
+
9
+ export function loadConfig() {
10
+ try {
11
+ const raw = fs.readFileSync(CONFIG_PATH, "utf8");
12
+ const parsed = JSON.parse(raw);
13
+ return parsed && typeof parsed === "object" ? parsed : {};
14
+ } catch {
15
+ return {};
16
+ }
17
+ }
18
+
19
+ export function saveConfig(partial) {
20
+ const config = { ...loadConfig(), ...partial };
21
+ for (const key of Object.keys(config)) {
22
+ if (config[key] === undefined || config[key] === null) delete config[key];
23
+ }
24
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
25
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", {
26
+ mode: 0o600,
27
+ });
28
+ try {
29
+ fs.chmodSync(CONFIG_PATH, 0o600);
30
+ } catch {
31
+ /* best effort on platforms without chmod */
32
+ }
33
+ return config;
34
+ }
35
+
36
+ export function resolveApiKey(flags = {}) {
37
+ if (flags.apiKey) return flags.apiKey;
38
+ if (process.env.FACELESS_API_KEY) return process.env.FACELESS_API_KEY;
39
+ const config = loadConfig();
40
+ return config.apiKey || null;
41
+ }
42
+
43
+ export function resolveBaseUrl(flags = {}) {
44
+ const url =
45
+ flags.apiUrl || process.env.FACELESS_API_URL || loadConfig().baseUrl || DEFAULT_BASE_URL;
46
+ return String(url).replace(/\/+$/, "");
47
+ }
File without changes