batchedits 1.0.0 → 1.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 CHANGED
@@ -1,47 +1,66 @@
1
- # `batchedits`
1
+ # BatchEdits CLI
2
2
 
3
- Official JS SDK, React Embed Widgets, and CLI for **BatchEdits AI Bulk Video Editor**.
4
-
5
- ## Installation
3
+ Edit up to 50 videos at once from your terminal or AI agent (Claude Code, OpenClaw, Hermes, Codex): auto captions in 99+ languages, silence removal, 9:16 crops, zoom on key moments and AI clips from long videos.
6
4
 
7
5
  ```bash
8
- npm install batchedits
6
+ npx batchedits login
7
+ npx batchedits style create "Shorts" --crop 9:16 --silence --captions --zoom
8
+ npx batchedits run ./raw --style "Shorts" --out ./edited
9
9
  ```
10
10
 
11
- ## React Usage
11
+ `run` uploads every video in `./raw`, edits them all with your style, waits, and saves the results to `./edited`.
12
12
 
13
- ```tsx
14
- import { EditWithBatchEditsButton } from 'batchedits/react';
15
-
16
- export function App() {
17
- return (
18
- <EditWithBatchEditsButton
19
- videoUrl="https://example.com/video.mp4"
20
- webhookUrl="https://example.com/api/webhooks/batchedits"
21
- theme="gradient"
22
- mode="popup"
23
- onComplete={(data) => console.log('Edited video:', data.editedVideoUrl)}
24
- />
25
- );
26
- }
13
+ Needs Node.js 18.17+ and a [BatchEdits](https://batchedits.com) account (7-day free trial, $0 today). Full docs: https://batchedits.com/docs/cli
14
+
15
+ ## Commands
16
+
17
+ | Command | What it does |
18
+ |---|---|
19
+ | `login` / `logout` / `whoami` | Sign in through your browser; check the connection |
20
+ | `styles` | List your styles |
21
+ | `actions` | List editing actions and their settings |
22
+ | `style create <name> [--captions] [--emojis] [--silence] [--repeats] [--crop 9:16] [--zoom] [--clips [n]]` | Create a style from flags |
23
+ | `upload <files or folders…>` | Upload up to 50 videos, print their ids |
24
+ | `process <ids…> --style <name> [--yes]` | Edit videos (1 credit each, 1 per AI clip) |
25
+ | `status [ids…] [--watch]` | Show status, or wait until done |
26
+ | `download <ids…> [--out dir]` | Save finished videos (and their AI clips) |
27
+ | `run <files or folders…> --style <name> [--out dir] [--yes]` | All of the above in one go |
28
+
29
+ Every command has `--help` with an example, and `--json` for machine-readable output.
30
+
31
+ ## Sign in
32
+
33
+ - `batchedits login` opens BatchEdits in your browser to approve the CLI, then stores the login in `~/.config/batchedits`. The token is never printed, so agents can run commands without seeing it.
34
+ - On servers and CI, set `BATCHEDITS_API_KEY` (Dashboard → Settings → API keys).
35
+
36
+ ## Using it from an AI agent
37
+
38
+ Agents on your machine can run the CLI directly. Add this to the agent's instructions:
39
+
40
+ ```
41
+ Edit videos with `npx batchedits` (run `npx batchedits --help`). List styles with `styles --json`,
42
+ then `run <folder> --style "<name>" --out <folder> --yes --json`. Processing uses credits:
43
+ confirm with the user before passing --yes. Exit code 5 = needs --yes, 3 = sign in first.
27
44
  ```
28
45
 
29
- ## Vanilla JavaScript Usage
46
+ For ChatGPT, claude.ai and other remote agents, connect the MCP server: https://batchedits.com/docs/mcp
47
+
48
+ Exit codes: `0` done · `1` error · `2` usage · `3` sign in needed · `4` some videos failed · `5` needs `--yes`.
49
+
50
+ ## SDK and React widgets
30
51
 
31
- ```javascript
32
- import { openModal } from 'batchedits';
52
+ The same package exports an embed SDK and React components for adding a BatchEdits editor to your app:
33
53
 
34
- openModal({
35
- videoUrl: 'https://example.com/video.mp4',
36
- mode: 'popup',
37
- onComplete: (data) => {
38
- console.log('Video editing complete!', data.editedVideoUrl);
39
- }
40
- });
54
+ ```tsx
55
+ import { EditWithBatchEditsButton } from "batchedits/react";
56
+
57
+ <EditWithBatchEditsButton videoUrl="https://example.com/video.mp4" mode="popup" onComplete={(d) => console.log(d.editedVideoUrl)} />
41
58
  ```
42
59
 
43
- ## CLI Usage
60
+ ```js
61
+ import { openModal } from "batchedits";
44
62
 
45
- ```bash
46
- npx batchedits --help
63
+ openModal({ videoUrl: "https://example.com/video.mp4", mode: "popup", onComplete: (data) => console.log(data.editedVideoUrl) });
47
64
  ```
65
+
66
+ MIT licensed.
package/dist/cli.js CHANGED
@@ -1,8 +1,698 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
18
+ // If the importer is in node compatibility mode or this is not an ESM
19
+ // file that has been converted to a CommonJS file using a Babel-
20
+ // compatible transform (i.e. "__esModule" has not been set), then set
21
+ // "default" to the CommonJS "module.exports" for node compatibility.
22
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
+ mod
24
+ ));
3
25
 
4
26
  // src/cli.ts
5
- console.log("\u{1F680} BatchEdits CLI v1.0.0");
6
- console.log("Commands available:");
7
- console.log(" batchedits edit <videoUrl> - Launch BatchEdits CLI video editor");
8
- console.log(" batchedits init - Generate BatchEdits config");
27
+ var import_node_util = require("util");
28
+
29
+ // src/cli/api.ts
30
+ var import_node_fs2 = __toESM(require("fs"));
31
+ var import_node_path2 = __toESM(require("path"));
32
+ var import_node_stream = require("stream");
33
+ var import_promises = require("stream/promises");
34
+ var import_client = require("@modelcontextprotocol/sdk/client/index.js");
35
+ var import_streamableHttp = require("@modelcontextprotocol/sdk/client/streamableHttp.js");
36
+
37
+ // src/cli/output.ts
38
+ var EXIT = {
39
+ OK: 0,
40
+ ERROR: 1,
41
+ USAGE: 2,
42
+ AUTH: 3,
43
+ /** Some items failed (e.g. one video in a batch). */
44
+ PARTIAL: 4,
45
+ /** Needs confirmation: re-run with --yes. */
46
+ CONFIRM: 5
47
+ };
48
+ var CliError = class extends Error {
49
+ constructor(message, code = EXIT.ERROR, hint) {
50
+ super(message);
51
+ this.code = code;
52
+ this.hint = hint;
53
+ }
54
+ };
55
+ var jsonMode = false;
56
+ function setJsonMode(on) {
57
+ jsonMode = on;
58
+ }
59
+ function isJson() {
60
+ return jsonMode;
61
+ }
62
+ function say(line = "") {
63
+ if (!jsonMode) process.stdout.write(line + "\n");
64
+ }
65
+ function info(line) {
66
+ process.stderr.write(line + "\n");
67
+ }
68
+ function printJson(value) {
69
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
70
+ }
71
+ function table(rows, columns) {
72
+ if (rows.length === 0) return;
73
+ const cell = (v) => v === null || v === void 0 ? "" : String(v);
74
+ const widths = columns.map((c) => Math.min(60, Math.max(c.length, ...rows.map((r) => cell(r[c]).length))));
75
+ const line = (vals) => vals.map((v, i) => v.slice(0, widths[i]).padEnd(widths[i])).join(" ").trimEnd();
76
+ say(line(columns.map((c) => c.toUpperCase())));
77
+ for (const r of rows) say(line(columns.map((c) => cell(r[c]))));
78
+ }
79
+
80
+ // src/cli/auth.ts
81
+ var import_node_crypto = __toESM(require("crypto"));
82
+ var import_node_http = __toESM(require("http"));
83
+ var import_node_child_process = require("child_process");
84
+
85
+ // src/cli/config.ts
86
+ var import_node_fs = __toESM(require("fs"));
87
+ var import_node_os = __toESM(require("os"));
88
+ var import_node_path = __toESM(require("path"));
89
+ var DEFAULT_HOST = "https://batchedits.com";
90
+ function configDir() {
91
+ const base = process.env.XDG_CONFIG_HOME || import_node_path.default.join(import_node_os.default.homedir(), ".config");
92
+ return import_node_path.default.join(base, "batchedits");
93
+ }
94
+ function authFile() {
95
+ return import_node_path.default.join(configDir(), "auth.json");
96
+ }
97
+ function readAuth() {
98
+ try {
99
+ return JSON.parse(import_node_fs.default.readFileSync(authFile(), "utf8"));
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+ function writeAuth(auth) {
105
+ import_node_fs.default.mkdirSync(configDir(), { recursive: true, mode: 448 });
106
+ import_node_fs.default.writeFileSync(authFile(), JSON.stringify(auth, null, 2), { mode: 384 });
107
+ }
108
+ function clearAuth() {
109
+ try {
110
+ import_node_fs.default.unlinkSync(authFile());
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+ function resolveHost(flag) {
117
+ const host = flag || process.env.BATCHEDITS_HOST || readAuth()?.host || DEFAULT_HOST;
118
+ return host.replace(/\/+$/, "");
119
+ }
120
+
121
+ // src/cli/auth.ts
122
+ async function discover(host) {
123
+ const res = await fetch(`${host}/.well-known/oauth-authorization-server`);
124
+ if (!res.ok) throw new CliError(`Couldn't reach ${host} (${res.status}).`);
125
+ return await res.json();
126
+ }
127
+ function openBrowser(url) {
128
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
129
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
130
+ try {
131
+ (0, import_node_child_process.spawn)(cmd, args, { stdio: "ignore", detached: true }).unref();
132
+ } catch {
133
+ }
134
+ }
135
+ var base64url = (b) => b.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
136
+ async function login(host) {
137
+ const meta = await discover(host);
138
+ const server = import_node_http.default.createServer();
139
+ await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
140
+ const port = server.address().port;
141
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
142
+ try {
143
+ const reg = await fetch(meta.registration_endpoint, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify({ client_name: "BatchEdits CLI", redirect_uris: [redirectUri] })
147
+ });
148
+ if (!reg.ok) throw new CliError(`Couldn't register the CLI with ${host} (${reg.status}).`);
149
+ const client = await reg.json();
150
+ const verifier = base64url(import_node_crypto.default.randomBytes(32));
151
+ const challenge = base64url(import_node_crypto.default.createHash("sha256").update(verifier).digest());
152
+ const state = base64url(import_node_crypto.default.randomBytes(16));
153
+ const authorizeUrl = new URL(meta.authorization_endpoint);
154
+ authorizeUrl.search = new URLSearchParams({
155
+ response_type: "code",
156
+ client_id: client.client_id,
157
+ redirect_uri: redirectUri,
158
+ state,
159
+ code_challenge: challenge,
160
+ code_challenge_method: "S256",
161
+ scope: "videos.read videos.write offline_access"
162
+ }).toString();
163
+ info("Opening your browser to sign in to BatchEdits\u2026");
164
+ info(`If it doesn't open, visit:
165
+ ${authorizeUrl}`);
166
+ openBrowser(authorizeUrl.toString());
167
+ const code = await new Promise((resolve, reject) => {
168
+ const timer = setTimeout(() => reject(new CliError("Login timed out after 5 minutes.", EXIT.AUTH)), 5 * 6e4);
169
+ server.on("request", (req, res) => {
170
+ const url = new URL(req.url || "/", redirectUri);
171
+ if (url.pathname !== "/callback") {
172
+ res.writeHead(404).end();
173
+ return;
174
+ }
175
+ const ok = url.searchParams.get("state") === state && url.searchParams.get("code");
176
+ res.writeHead(ok ? 200 : 400, { "Content-Type": "text/html" }).end(
177
+ ok ? "<h2>You're signed in to the BatchEdits CLI.</h2><p>You can close this tab.</p>" : "<h2>Login failed.</h2><p>Run <code>batchedits login</code> again.</p>"
178
+ );
179
+ clearTimeout(timer);
180
+ if (ok) resolve(url.searchParams.get("code"));
181
+ else reject(new CliError(url.searchParams.get("error_description") || "Login was cancelled or failed.", EXIT.AUTH));
182
+ });
183
+ });
184
+ const tokens = await tokenRequest(meta.token_endpoint, {
185
+ grant_type: "authorization_code",
186
+ code,
187
+ redirect_uri: redirectUri,
188
+ code_verifier: verifier,
189
+ client_id: client.client_id,
190
+ client_secret: client.client_secret
191
+ });
192
+ const auth = {
193
+ host,
194
+ clientId: client.client_id,
195
+ clientSecret: client.client_secret,
196
+ redirectUri,
197
+ accessToken: tokens.access_token,
198
+ refreshToken: tokens.refresh_token,
199
+ expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1e3 : void 0
200
+ };
201
+ writeAuth(auth);
202
+ return auth;
203
+ } finally {
204
+ server.close();
205
+ }
206
+ }
207
+ async function tokenRequest(endpoint, params) {
208
+ const res = await fetch(endpoint, {
209
+ method: "POST",
210
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
211
+ body: new URLSearchParams(params).toString()
212
+ });
213
+ const body = await res.json().catch(() => ({}));
214
+ if (!res.ok || !body.access_token) {
215
+ throw new CliError(`Sign-in failed: ${body.error_description || body.error || res.status}.`, EXIT.AUTH, "Run: batchedits login");
216
+ }
217
+ return body;
218
+ }
219
+ async function getToken(host) {
220
+ const apiKey = process.env.BATCHEDITS_API_KEY?.trim();
221
+ if (apiKey) return apiKey;
222
+ const auth = readAuth();
223
+ if (!auth || auth.host !== host) {
224
+ throw new CliError("Not signed in.", EXIT.AUTH, "Run: batchedits login (or set BATCHEDITS_API_KEY)");
225
+ }
226
+ if (!auth.expiresAt || auth.expiresAt - Date.now() > 6e4) return auth.accessToken;
227
+ if (!auth.refreshToken) throw new CliError("Your login expired.", EXIT.AUTH, "Run: batchedits login");
228
+ const meta = await discover(host);
229
+ const tokens = await tokenRequest(meta.token_endpoint, {
230
+ grant_type: "refresh_token",
231
+ refresh_token: auth.refreshToken,
232
+ client_id: auth.clientId,
233
+ client_secret: auth.clientSecret
234
+ });
235
+ writeAuth({
236
+ ...auth,
237
+ accessToken: tokens.access_token,
238
+ refreshToken: tokens.refresh_token ?? auth.refreshToken,
239
+ expiresAt: tokens.expires_in ? Date.now() + tokens.expires_in * 1e3 : void 0
240
+ });
241
+ return tokens.access_token;
242
+ }
243
+
244
+ // src/cli/api.ts
245
+ var BatchEdits = class {
246
+ constructor(host, version) {
247
+ this.host = host;
248
+ this.version = version;
249
+ }
250
+ async connect() {
251
+ if (this.client) return this.client;
252
+ this.token = await getToken(this.host);
253
+ const transport = new import_streamableHttp.StreamableHTTPClientTransport(new URL(`${this.host}/api/mcp`), {
254
+ requestInit: { headers: { Authorization: `Bearer ${this.token}` } }
255
+ });
256
+ const client = new import_client.Client({ name: "batchedits-cli", version: this.version });
257
+ try {
258
+ await client.connect(transport);
259
+ } catch (err) {
260
+ if (String(err?.message).includes("401")) throw new CliError("Your login isn't valid any more.", EXIT.AUTH, "Run: batchedits login");
261
+ throw new CliError(`Couldn't connect to ${this.host}: ${err?.message ?? err}`);
262
+ }
263
+ this.client = client;
264
+ return client;
265
+ }
266
+ async close() {
267
+ await this.client?.close().catch(() => {
268
+ });
269
+ }
270
+ /** Calls a tool and returns its structured result; tool errors become CliErrors with the server's message. */
271
+ async call(name, args = {}) {
272
+ const client = await this.connect();
273
+ const res = await client.callTool({ name, arguments: args });
274
+ if (res.isError) {
275
+ const text = (res.content ?? []).map((c) => c.text).filter(Boolean).join(" ").replace(/^Error:\s*/, "");
276
+ throw new CliError(text || `${name} failed.`, EXIT.ERROR, creditsHint(text, this.host));
277
+ }
278
+ return res.structuredContent ?? {};
279
+ }
280
+ async styles() {
281
+ return (await this.call("get_styles")).styles ?? [];
282
+ }
283
+ async videos(limit = 100) {
284
+ return (await this.call("list_videos", { limit })).videos ?? [];
285
+ }
286
+ /** Uploads one local file (multipart, one per request so a failure only costs that file). */
287
+ async upload(file) {
288
+ await this.connect();
289
+ const form = new FormData();
290
+ const blob = typeof import_node_fs2.default.openAsBlob === "function" ? await import_node_fs2.default.openAsBlob(file) : new Blob([new Uint8Array(import_node_fs2.default.readFileSync(file))]);
291
+ form.append("videos", blob, import_node_path2.default.basename(file));
292
+ const res = await fetch(`${this.host}/api/mcp/upload`, { method: "POST", headers: { Authorization: `Bearer ${this.token}` }, body: form });
293
+ const body = await res.json().catch(() => ({}));
294
+ if (!res.ok || !body.videos?.[0]) {
295
+ if (res.status === 401) throw new CliError("Your login isn't valid any more.", EXIT.AUTH, "Run: batchedits login");
296
+ if (res.status === 413) throw new CliError(`${import_node_path2.default.basename(file)} is too large to upload.`);
297
+ throw new CliError(`Upload failed for ${import_node_path2.default.basename(file)}: ${body.error ?? res.status}`);
298
+ }
299
+ return body.videos[0];
300
+ }
301
+ /** Streams a download link to `dir`, never overwriting an existing file. */
302
+ async download(url, name, dir) {
303
+ import_node_fs2.default.mkdirSync(dir, { recursive: true });
304
+ const ext = import_node_path2.default.extname(name) || ".mp4";
305
+ const stem = import_node_path2.default.basename(name, import_node_path2.default.extname(name));
306
+ let out = import_node_path2.default.join(dir, `${stem}${ext}`);
307
+ for (let n = 2; import_node_fs2.default.existsSync(out); n++) out = import_node_path2.default.join(dir, `${stem}-${n}${ext}`);
308
+ const res = await fetch(url);
309
+ if (!res.ok || !res.body) throw new CliError(`Download failed for ${name} (${res.status}).`);
310
+ await (0, import_promises.pipeline)(import_node_stream.Readable.fromWeb(res.body), import_node_fs2.default.createWriteStream(out));
311
+ return out;
312
+ }
313
+ };
314
+ function creditsHint(message, host) {
315
+ if (/credit/i.test(message)) return `Start your free trial or upgrade: ${host}/onboarding/pricing`;
316
+ return void 0;
317
+ }
318
+
319
+ // src/cli/commands.ts
320
+ var import_node_fs3 = __toESM(require("fs"));
321
+ var import_node_path3 = __toESM(require("path"));
322
+ var import_promises2 = __toESM(require("readline/promises"));
323
+ var VIDEO_EXTS = /* @__PURE__ */ new Set([".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm", ".mp3", ".wav", ".m4a"]);
324
+ var MAX_BATCH = 50;
325
+ var DONE = /* @__PURE__ */ new Set(["completed", "failed"]);
326
+ function expandFiles(inputs) {
327
+ const files = [];
328
+ for (const input of inputs) {
329
+ if (!import_node_fs3.default.existsSync(input)) throw new CliError(`No such file or folder: ${input}`, EXIT.USAGE);
330
+ if (import_node_fs3.default.statSync(input).isDirectory()) {
331
+ for (const f of import_node_fs3.default.readdirSync(input).sort()) {
332
+ const p = import_node_path3.default.join(input, f);
333
+ if (import_node_fs3.default.statSync(p).isFile() && VIDEO_EXTS.has(import_node_path3.default.extname(f).toLowerCase())) files.push(p);
334
+ }
335
+ } else {
336
+ files.push(input);
337
+ }
338
+ }
339
+ if (files.length === 0) throw new CliError("No video or audio files found.", EXIT.USAGE, `Supported: ${Array.from(VIDEO_EXTS).join(" ")}`);
340
+ if (files.length > MAX_BATCH) throw new CliError(`${files.length} files: upload up to ${MAX_BATCH} at a time.`, EXIT.USAGE, "Split the folder and run once per part.");
341
+ return files;
342
+ }
343
+ async function resolveStyle(api, ref) {
344
+ if (typeof ref !== "string" || !ref) throw new CliError("--style is required.", EXIT.USAGE, "List them with: batchedits styles");
345
+ const styles = await api.styles();
346
+ const style = styles.find((s) => s.id === ref) ?? styles.find((s) => s.name.toLowerCase() === ref.toLowerCase());
347
+ if (!style) throw new CliError(`No style named "${ref}".`, EXIT.USAGE, `Available: ${styles.map((s) => `"${s.name}"`).join(", ") || "none"}. Create one with: batchedits style create`);
348
+ return style;
349
+ }
350
+ async function confirm(flags, question) {
351
+ if (flags.yes) return;
352
+ if (!process.stdin.isTTY || isJson()) throw new CliError(question, EXIT.CONFIRM, "Re-run with --yes to confirm.");
353
+ const rl = import_promises2.default.createInterface({ input: process.stdin, output: process.stderr });
354
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
355
+ rl.close();
356
+ if (answer !== "y" && answer !== "yes") throw new CliError("Cancelled. Nothing was processed.", EXIT.ERROR);
357
+ }
358
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
359
+ function withClips(all, ids) {
360
+ const wanted = new Set(ids);
361
+ return all.filter((v) => wanted.has(v.id) || v.clipOf && wanted.has(v.clipOf));
362
+ }
363
+ function settled(v, group) {
364
+ if (v.splitIntoClips) return group.filter((c) => c.clipOf === v.id).every((c) => DONE.has(c.status));
365
+ return DONE.has(v.status);
366
+ }
367
+ async function watch(api, ids, intervalSec) {
368
+ let last = "";
369
+ for (; ; ) {
370
+ const group = withClips(await api.videos(), ids);
371
+ const missing = ids.filter((id) => !group.some((v) => v.id === id));
372
+ if (missing.length) throw new CliError(`Unknown video id(s): ${missing.join(", ")}`, EXIT.USAGE, "List your videos with: batchedits status");
373
+ const done = group.filter((v) => settled(v, group)).length;
374
+ const line = `${done}/${group.length} done \xB7 ${group.map((v) => `${v.name.slice(0, 24)}: ${v.splitIntoClips ? `split into ${v.splitIntoClips} clips` : v.progressMessage || v.status}`).join(" \xB7 ")}`;
375
+ if (line !== last) info(line);
376
+ last = line;
377
+ if (group.every((v) => settled(v, group))) return group;
378
+ await sleep(intervalSec * 1e3);
379
+ }
380
+ }
381
+ function printVideos(videos) {
382
+ if (isJson()) return printJson({ videos });
383
+ if (videos.length === 0) return say("No videos.");
384
+ table(
385
+ videos.map((v) => ({
386
+ id: v.id,
387
+ name: v.name,
388
+ status: v.splitIntoClips ? `split (${v.splitIntoClips} clips)` : v.status,
389
+ detail: v.errorMessage || v.progressMessage || (v.clipOf ? `clip of ${v.clipOf.slice(0, 8)}` : "")
390
+ })),
391
+ ["id", "name", "status", "detail"]
392
+ );
393
+ }
394
+ async function downloadAll(api, videos, out) {
395
+ const ready = videos.filter((v) => v.status === "completed" && !v.splitIntoClips);
396
+ if (ready.length === 0) return { saved: [], notReady: videos.filter((v) => !v.splitIntoClips).map((v) => ({ id: v.id, status: v.status })) };
397
+ const res = await api.call("download_videos", { videoIds: ready.map((v) => v.id) });
398
+ const saved = [];
399
+ for (const link of res.links ?? []) {
400
+ const file = await api.download(link.url, link.name, out);
401
+ info(`Saved ${file}`);
402
+ saved.push({ id: link.id, file });
403
+ }
404
+ return { saved, notReady: res.notReady ?? [] };
405
+ }
406
+ var commands = {
407
+ login: {
408
+ summary: "Sign in through your browser",
409
+ usage: "batchedits login [--host <url>]",
410
+ help: "Opens BatchEdits in your browser to approve the CLI, then stores the login in ~/.config/batchedits.\nThe token is never printed, so agents can run commands without seeing it.\nOn servers and CI, set BATCHEDITS_API_KEY instead (Dashboard \u2192 Settings \u2192 API keys).",
411
+ async run({ host }) {
412
+ await login(host);
413
+ if (isJson()) printJson({ loggedIn: true, host });
414
+ else say(`Signed in to ${host}. Try: batchedits styles`);
415
+ return EXIT.OK;
416
+ }
417
+ },
418
+ logout: {
419
+ summary: "Remove the stored login",
420
+ usage: "batchedits logout",
421
+ help: "Deletes ~/.config/batchedits/auth.json. BATCHEDITS_API_KEY, if set, still works.",
422
+ async run() {
423
+ const removed = clearAuth();
424
+ if (isJson()) printJson({ loggedOut: removed });
425
+ else say(removed ? "Signed out." : "You weren't signed in.");
426
+ return EXIT.OK;
427
+ }
428
+ },
429
+ whoami: {
430
+ summary: "Check that you're signed in",
431
+ usage: "batchedits whoami",
432
+ help: "Shows how the CLI is authenticated and checks the connection.",
433
+ async run({ api, host }) {
434
+ const via = process.env.BATCHEDITS_API_KEY ? "BATCHEDITS_API_KEY" : readAuth() ? `login (${authFile()})` : "nothing";
435
+ const styles = await api.styles();
436
+ if (isJson()) printJson({ host, auth: via, styles: styles.length });
437
+ else say(`Connected to ${host} via ${via}. ${styles.length} styles available.`);
438
+ return EXIT.OK;
439
+ }
440
+ },
441
+ styles: {
442
+ summary: "List your editing styles",
443
+ usage: "batchedits styles",
444
+ help: "Lists saved and built-in styles. Use a style's name or id with --style.\nExample: batchedits styles",
445
+ async run({ api }) {
446
+ const styles = await api.styles();
447
+ if (isJson()) printJson({ styles });
448
+ else if (styles.length === 0) say("No styles yet. Create one with: batchedits style create");
449
+ else table(styles.map((s) => ({ name: s.name, id: s.id, actions: (s.actions ?? []).map((a) => a.name).join(", ") })), ["name", "id", "actions"]);
450
+ return EXIT.OK;
451
+ }
452
+ },
453
+ actions: {
454
+ summary: "List editing actions and their settings",
455
+ usage: "batchedits actions",
456
+ help: "Lists the actions a style can use (captions, silence removal, crop, zoom, AI clips) and their params.",
457
+ async run({ api }) {
458
+ const { actions } = await api.call("list_actions");
459
+ if (isJson()) printJson({ actions });
460
+ else table(actions.map((a) => ({ name: a.name, params: a.params ?? a.description ?? "" })), ["name", "params"]);
461
+ return EXIT.OK;
462
+ }
463
+ },
464
+ "style create": {
465
+ summary: "Create a style from simple flags",
466
+ usage: "batchedits style create <name> [--captions] [--emojis] [--silence] [--crop 9:16] [--zoom] [--clips [n]] [--repeats]",
467
+ help: [
468
+ "Creates a reusable style. Order of edits: crop, silence, repeats, captions, zoom, AI clips.",
469
+ " --captions Auto captions (word-by-word highlight)",
470
+ " --emojis Add AI-picked emojis to the captions",
471
+ " --silence Remove silences and dead air",
472
+ " --repeats Remove repeated takes of the same sentence",
473
+ " --crop <ratio> Crop every video, e.g. 9:16, 1:1, 4:5",
474
+ " --zoom Zoom in on key moments",
475
+ " --clips [n] Cut long videos into n short clips (default 10; 1 credit per clip)",
476
+ 'Example: batchedits style create "Shorts" --crop 9:16 --silence --captions --zoom'
477
+ ].join("\n"),
478
+ async run({ api, positionals, flags }) {
479
+ const name = positionals.join(" ").trim();
480
+ if (!name) throw new CliError("Give the style a name.", EXIT.USAGE, 'Example: batchedits style create "Shorts" --captions --silence');
481
+ const { actions } = await api.call("list_actions");
482
+ const byName = new Map(actions.map((a) => [a.name, a.id]));
483
+ const wanted = [];
484
+ if (typeof flags.crop === "string") wanted.push({ name: "crop_video", customParams: { aspectRatio: flags.crop } });
485
+ if (flags.silence) wanted.push({ name: "remove_silence" });
486
+ if (flags.repeats) wanted.push({ name: "remove_repeated_sentences" });
487
+ if (flags.captions || flags.emojis) wanted.push({ name: "add_captions", customParams: flags.emojis ? { emojis: true } : void 0 });
488
+ if (flags.zoom) wanted.push({ name: "punch_zoom" });
489
+ if (flags.clips) wanted.push({ name: "ai_clips", customParams: typeof flags.clips === "string" ? { maxClips: Number(flags.clips) } : void 0 });
490
+ if (wanted.length === 0) throw new CliError("Pick at least one edit.", EXIT.USAGE, "See: batchedits style create --help");
491
+ const missing = wanted.filter((w) => !byName.has(w.name)).map((w) => w.name);
492
+ if (missing.length) throw new CliError(`This server doesn't support: ${missing.join(", ")}.`);
493
+ const res = await api.call("create_style", {
494
+ name,
495
+ actions: wanted.map((w) => ({ id: byName.get(w.name), ...w.customParams ? { customParams: w.customParams } : {} }))
496
+ });
497
+ if (isJson()) printJson({ styleId: res.styleId, name });
498
+ else say(`Created style "${name}" (${res.styleId}). Use it with: --style "${name}"`);
499
+ return EXIT.OK;
500
+ }
501
+ },
502
+ upload: {
503
+ summary: "Upload local videos (up to 50)",
504
+ usage: "batchedits upload <files or folders\u2026>",
505
+ help: "Uploads videos or audio and prints their ids. A folder uploads the media files inside it.\nExample: batchedits upload ./raw",
506
+ async run({ api, positionals }) {
507
+ const files = expandFiles(positionals);
508
+ const uploaded = [];
509
+ for (let i = 0; i < files.length; i++) {
510
+ info(`Uploading ${i + 1}/${files.length}: ${import_node_path3.default.basename(files[i])}`);
511
+ uploaded.push({ ...await api.upload(files[i]), file: files[i] });
512
+ }
513
+ if (isJson()) printJson({ videos: uploaded });
514
+ else {
515
+ table(uploaded, ["id", "name"]);
516
+ say(`
517
+ Next: batchedits process ${uploaded.map((v) => v.id).join(" ")} --style "<name>"`);
518
+ }
519
+ return EXIT.OK;
520
+ }
521
+ },
522
+ process: {
523
+ summary: "Edit videos with a style (uses credits)",
524
+ usage: "batchedits process <video ids\u2026> --style <name|id> [--yes]",
525
+ help: 'Queues each video with the style. Each video uses 1 credit; with AI clips each clip uses 1.\nAsks before spending credits; agents and scripts pass --yes.\nExample: batchedits process 3f2a\u2026 9c1d\u2026 --style "Shorts" --yes',
526
+ async run({ api, positionals, flags }) {
527
+ if (positionals.length === 0) throw new CliError("Give the video ids to process.", EXIT.USAGE, "Find them with: batchedits status");
528
+ const style = await resolveStyle(api, flags.style);
529
+ const clips = (style.actions ?? []).some((a) => a.name === "ai_clips");
530
+ await confirm(flags, `Edit ${positionals.length} video${positionals.length === 1 ? "" : "s"} with "${style.name}"? This uses ${positionals.length} credit${positionals.length === 1 ? "" : "s"}${clips ? ", plus 1 per AI clip" : ""}.`);
531
+ const results = [];
532
+ for (const videoId of positionals) {
533
+ try {
534
+ const r = await api.call("process_video", { videoId, styleId: style.id });
535
+ results.push({ videoId, status: r.status ?? "queued" });
536
+ } catch (err) {
537
+ if (err instanceof CliError && err.hint && results.length === 0) throw err;
538
+ results.push({ videoId, error: err?.message ?? String(err) });
539
+ }
540
+ }
541
+ if (isJson()) printJson({ style: style.name, results });
542
+ else {
543
+ table(results.map((r) => ({ id: r.videoId, result: r.error ?? r.status })), ["id", "result"]);
544
+ say(`
545
+ Next: batchedits status ${positionals.join(" ")} --watch`);
546
+ }
547
+ return results.some((r) => r.error) ? EXIT.PARTIAL : EXIT.OK;
548
+ }
549
+ },
550
+ status: {
551
+ summary: "Show video status (or --watch until done)",
552
+ usage: "batchedits status [video ids\u2026] [--watch] [--interval 10]",
553
+ help: "Without ids: your 20 newest videos. With --watch: waits until every given video (and its AI clips) is done.\nExample: batchedits status 3f2a\u2026 --watch",
554
+ async run({ api, positionals, flags }) {
555
+ if (flags.watch) {
556
+ if (positionals.length === 0) throw new CliError("--watch needs video ids.", EXIT.USAGE);
557
+ const group = await watch(api, positionals, Number(flags.interval) || 10);
558
+ printVideos(group);
559
+ return group.some((v) => v.status === "failed") ? EXIT.PARTIAL : EXIT.OK;
560
+ }
561
+ const all = await api.videos();
562
+ printVideos(positionals.length ? withClips(all, positionals) : all.slice(0, 20));
563
+ return EXIT.OK;
564
+ }
565
+ },
566
+ download: {
567
+ summary: "Download finished videos",
568
+ usage: "batchedits download <video ids\u2026> [--out ./edited]",
569
+ help: "Saves every finished video (and AI clips cut from it) to --out (default ./edited). Unfinished ones are listed.\nExample: batchedits download 3f2a\u2026 --out ./edited",
570
+ async run({ api, positionals, flags }) {
571
+ if (positionals.length === 0) throw new CliError("Give the video ids to download.", EXIT.USAGE);
572
+ const group = withClips(await api.videos(), positionals);
573
+ const { saved, notReady } = await downloadAll(api, group, typeof flags.out === "string" ? flags.out : "edited");
574
+ if (isJson()) printJson({ saved, notReady });
575
+ else {
576
+ say(`Downloaded ${saved.length} video${saved.length === 1 ? "" : "s"}.`);
577
+ for (const n of notReady) say(`Not ready: ${n.id} (${n.status})`);
578
+ }
579
+ return notReady.length ? EXIT.PARTIAL : EXIT.OK;
580
+ }
581
+ },
582
+ run: {
583
+ summary: "Upload, edit, wait and download in one go",
584
+ usage: "batchedits run <files or folders\u2026> --style <name|id> [--out ./edited] [--yes]",
585
+ help: 'Uploads up to 50 videos, edits them with the style, waits, and downloads the results.\nExample: batchedits run ./raw --style "Shorts" --out ./edited --yes',
586
+ async run(ctx) {
587
+ const { api, positionals, flags } = ctx;
588
+ const files = expandFiles(positionals);
589
+ const style = await resolveStyle(api, flags.style);
590
+ const clips = (style.actions ?? []).some((a) => a.name === "ai_clips");
591
+ await confirm(flags, `Edit ${files.length} video${files.length === 1 ? "" : "s"} with "${style.name}"? This uses ${files.length} credit${files.length === 1 ? "" : "s"}${clips ? ", plus 1 per AI clip" : ""}.`);
592
+ const ids = [];
593
+ for (let i = 0; i < files.length; i++) {
594
+ info(`Uploading ${i + 1}/${files.length}: ${import_node_path3.default.basename(files[i])}`);
595
+ ids.push((await api.upload(files[i])).id);
596
+ }
597
+ for (const videoId of ids) await api.call("process_video", { videoId, styleId: style.id });
598
+ info(`Editing ${ids.length} video${ids.length === 1 ? "" : "s"} with "${style.name}"\u2026`);
599
+ const group = await watch(api, ids, Number(flags.interval) || 10);
600
+ const { saved, notReady } = await downloadAll(api, group, typeof flags.out === "string" ? flags.out : "edited");
601
+ const failed = group.filter((v) => v.status === "failed");
602
+ if (isJson()) printJson({ style: style.name, videoIds: ids, saved, failed: failed.map((v) => ({ id: v.id, error: v.errorMessage })), notReady });
603
+ else {
604
+ say(`Done: ${saved.length} edited video${saved.length === 1 ? "" : "s"} in ${typeof flags.out === "string" ? flags.out : "edited"}/`);
605
+ for (const v of failed) say(`Failed: ${v.name}${v.errorMessage ? ` (${v.errorMessage})` : ""}`);
606
+ }
607
+ return failed.length || notReady.length ? EXIT.PARTIAL : EXIT.OK;
608
+ }
609
+ }
610
+ };
611
+
612
+ // src/cli.ts
613
+ var VERSION = true ? "1.1.0" : "dev";
614
+ var FLAG_OPTIONS = {
615
+ help: { type: "boolean", short: "h" },
616
+ version: { type: "boolean", short: "v" },
617
+ json: { type: "boolean" },
618
+ yes: { type: "boolean", short: "y" },
619
+ host: { type: "string" },
620
+ style: { type: "string", short: "s" },
621
+ out: { type: "string", short: "o" },
622
+ watch: { type: "boolean", short: "w" },
623
+ interval: { type: "string" },
624
+ captions: { type: "boolean" },
625
+ emojis: { type: "boolean" },
626
+ silence: { type: "boolean" },
627
+ repeats: { type: "boolean" },
628
+ crop: { type: "string" },
629
+ zoom: { type: "boolean" },
630
+ clips: { type: "string" }
631
+ };
632
+ function mainHelp() {
633
+ const names = Object.keys(commands);
634
+ const width = Math.max(...names.map((n) => n.length));
635
+ return [
636
+ `BatchEdits CLI ${VERSION}: edit up to 50 videos at once.`,
637
+ "",
638
+ "Usage: batchedits <command> [options]",
639
+ "",
640
+ "Commands:",
641
+ ...names.map((n) => ` ${n.padEnd(width)} ${commands[n].summary}`),
642
+ "",
643
+ "Options: --json (machine-readable output) --yes (confirm credit use) --host <url> -h, --help",
644
+ "Auth: batchedits login, or set BATCHEDITS_API_KEY.",
645
+ "",
646
+ "Quick start:",
647
+ " batchedits login",
648
+ ' batchedits style create "Shorts" --crop 9:16 --silence --captions --zoom',
649
+ ' batchedits run ./raw --style "Shorts" --out ./edited',
650
+ "",
651
+ "Exit codes: 0 ok \xB7 1 error \xB7 2 usage \xB7 3 sign in needed \xB7 4 some videos failed \xB7 5 needs --yes"
652
+ ].join("\n");
653
+ }
654
+ function normalizeArgv(argv) {
655
+ return argv.flatMap((a, i) => a === "--clips" && (argv[i + 1] === void 0 || argv[i + 1].startsWith("-")) ? ["--clips="] : [a]);
656
+ }
657
+ async function main() {
658
+ const { values, positionals } = (0, import_node_util.parseArgs)({ args: normalizeArgv(process.argv.slice(2)), options: FLAG_OPTIONS, allowPositionals: true, strict: true });
659
+ const flags = values;
660
+ if (flags.clips === "") flags.clips = true;
661
+ setJsonMode(Boolean(flags.json));
662
+ if (flags.version) {
663
+ console.log(VERSION);
664
+ return EXIT.OK;
665
+ }
666
+ const two = positionals.slice(0, 2).join(" ");
667
+ const name = commands[two] ? two : positionals[0];
668
+ const command = name ? commands[name] : void 0;
669
+ if (!command) {
670
+ if (name) info(`Unknown command: ${name}
671
+ `);
672
+ console.log(mainHelp());
673
+ return name ? EXIT.USAGE : EXIT.OK;
674
+ }
675
+ if (flags.help) {
676
+ console.log(`${command.usage}
677
+
678
+ ${command.help}`);
679
+ return EXIT.OK;
680
+ }
681
+ const host = resolveHost(typeof flags.host === "string" ? flags.host : void 0);
682
+ const api = new BatchEdits(host, VERSION);
683
+ try {
684
+ return await command.run({ api, host, positionals: positionals.slice(name.split(" ").length), flags });
685
+ } finally {
686
+ await api.close();
687
+ }
688
+ }
689
+ main().then(
690
+ (code) => process.exit(code),
691
+ (err) => {
692
+ const e = err instanceof CliError ? err : new CliError(err?.message ?? String(err), err?.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? EXIT.USAGE : EXIT.ERROR);
693
+ if (isJson()) printJson({ error: e.message, hint: e.hint ?? null, exitCode: e.code });
694
+ else info(`Error: ${e.message}${e.hint ? `
695
+ ${e.hint}` : ""}`);
696
+ process.exit(e.code);
697
+ }
698
+ );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "batchedits",
3
- "version": "1.0.0",
4
- "description": "Official SDK, React Embed Widgets, and CLI for BatchEdits AI Bulk Video Editor",
3
+ "version": "1.1.0",
4
+ "description": "BatchEdits CLI, SDK and React widgets: edit up to 50 videos at once from your terminal or AI agent (Claude Code, OpenClaw, Hermes)",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.mjs",
7
7
  "types": "./dist/index.d.ts",
@@ -24,7 +24,8 @@
24
24
  "dist"
25
25
  ],
26
26
  "scripts": {
27
- "build": "tsup src/index.ts src/react.tsx src/cli.ts --format cjs,esm --dts --clean",
27
+ "build": "tsup",
28
+ "typecheck": "tsc --noEmit",
28
29
  "prepublishOnly": "npm run build"
29
30
  },
30
31
  "keywords": [
@@ -33,7 +34,13 @@
33
34
  "auto-captions",
34
35
  "react-widget",
35
36
  "embed",
36
- "cli"
37
+ "cli",
38
+ "mcp",
39
+ "ai-agent",
40
+ "claude-code",
41
+ "openclaw",
42
+ "bulk-video-editor",
43
+ "silence-remover"
37
44
  ],
38
45
  "author": "BatchEdits",
39
46
  "license": "MIT",
@@ -53,5 +60,11 @@
53
60
  "@types/react": "^18.0.0",
54
61
  "tsup": "^8.0.0",
55
62
  "typescript": "^5.0.0"
63
+ },
64
+ "dependencies": {
65
+ "@modelcontextprotocol/sdk": "^1.32.1"
66
+ },
67
+ "engines": {
68
+ "node": ">=18.17"
56
69
  }
57
70
  }
package/dist/cli.d.mts DELETED
@@ -1 +0,0 @@
1
- #!/usr/bin/env node
package/dist/cli.d.ts DELETED
@@ -1 +0,0 @@
1
- #!/usr/bin/env node
package/dist/cli.mjs DELETED
@@ -1,7 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/cli.ts
4
- console.log("\u{1F680} BatchEdits CLI v1.0.0");
5
- console.log("Commands available:");
6
- console.log(" batchedits edit <videoUrl> - Launch BatchEdits CLI video editor");
7
- console.log(" batchedits init - Generate BatchEdits config");