videodraft 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +118 -2
- package/dist/client.d.ts +363 -0
- package/dist/client.js +870 -0
- package/dist/index.js +2742 -0
- package/package.json +63 -4
- package/skills/videodraft/SKILL.md +95 -0
- package/skills/videodraft/references/examples.md +84 -0
- package/skills/videodraft/references/models.md +43 -0
- package/skills/videodraft/references/pipeline.md +59 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2742 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/version.ts
|
|
13
|
+
var version_exports = {};
|
|
14
|
+
__export(version_exports, {
|
|
15
|
+
VERSION: () => VERSION
|
|
16
|
+
});
|
|
17
|
+
import fs from "fs";
|
|
18
|
+
function readVersion() {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
21
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
22
|
+
} catch {
|
|
23
|
+
return "0.0.0";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
var VERSION;
|
|
27
|
+
var init_version = __esm({
|
|
28
|
+
"src/version.ts"() {
|
|
29
|
+
"use strict";
|
|
30
|
+
VERSION = readVersion();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// src/index.ts
|
|
35
|
+
import { Command, CommanderError } from "commander";
|
|
36
|
+
|
|
37
|
+
// src/core/errors.ts
|
|
38
|
+
var EXIT = {
|
|
39
|
+
OK: 0,
|
|
40
|
+
ERROR: 1,
|
|
41
|
+
USAGE: 2,
|
|
42
|
+
AUTH: 3,
|
|
43
|
+
CREDITS: 4
|
|
44
|
+
};
|
|
45
|
+
var CliError = class extends Error {
|
|
46
|
+
exitCode;
|
|
47
|
+
/** One-line remediation shown under the error (human mode only). */
|
|
48
|
+
hint;
|
|
49
|
+
constructor(message, exitCode = EXIT.ERROR, hint) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "CliError";
|
|
52
|
+
this.exitCode = exitCode;
|
|
53
|
+
this.hint = hint;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
var AuthError = class extends CliError {
|
|
57
|
+
constructor(message = "Not authenticated.") {
|
|
58
|
+
super(
|
|
59
|
+
message,
|
|
60
|
+
EXIT.AUTH,
|
|
61
|
+
"Run `videodraft login` (or set VIDEODRAFT_API_KEY to a vd_mcp_... token from /mcp-keys)."
|
|
62
|
+
);
|
|
63
|
+
this.name = "AuthError";
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var RpcError = class extends CliError {
|
|
67
|
+
code;
|
|
68
|
+
data;
|
|
69
|
+
constructor(code, message, data) {
|
|
70
|
+
super(`RPC error ${code}: ${message}`);
|
|
71
|
+
this.name = "RpcError";
|
|
72
|
+
this.code = code;
|
|
73
|
+
this.data = data;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var CREDIT_ERROR_RE = /insufficient credits|not enough credits|credit balance/i;
|
|
77
|
+
var ToolError = class extends CliError {
|
|
78
|
+
toolName;
|
|
79
|
+
constructor(toolName, message) {
|
|
80
|
+
const isCredits = CREDIT_ERROR_RE.test(message);
|
|
81
|
+
super(
|
|
82
|
+
message,
|
|
83
|
+
isCredits ? EXIT.CREDITS : EXIT.ERROR,
|
|
84
|
+
isCredits ? "Check your balance with `videodraft credits` or top up at https://app.videodraft.ai/pricing" : void 0
|
|
85
|
+
);
|
|
86
|
+
this.name = "ToolError";
|
|
87
|
+
this.toolName = toolName;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
var TimeoutError = class extends CliError {
|
|
91
|
+
constructor(message) {
|
|
92
|
+
super(message, EXIT.ERROR);
|
|
93
|
+
this.name = "TimeoutError";
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// src/index.ts
|
|
98
|
+
init_version();
|
|
99
|
+
|
|
100
|
+
// src/cli/output.ts
|
|
101
|
+
import pc from "picocolors";
|
|
102
|
+
function makeOutput(opts) {
|
|
103
|
+
const isTTY = Boolean(process.stdout.isTTY);
|
|
104
|
+
const noColorEnv = Boolean(process.env.NO_COLOR);
|
|
105
|
+
return {
|
|
106
|
+
json: Boolean(opts.json),
|
|
107
|
+
color: (opts.color ?? true) && !noColorEnv && isTTY,
|
|
108
|
+
isTTY
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
var fmt = {
|
|
112
|
+
dim: (out, s) => out.color ? pc.dim(s) : s,
|
|
113
|
+
bold: (out, s) => out.color ? pc.bold(s) : s,
|
|
114
|
+
green: (out, s) => out.color ? pc.green(s) : s,
|
|
115
|
+
red: (out, s) => out.color ? pc.red(s) : s,
|
|
116
|
+
yellow: (out, s) => out.color ? pc.yellow(s) : s,
|
|
117
|
+
cyan: (out, s) => out.color ? pc.cyan(s) : s
|
|
118
|
+
};
|
|
119
|
+
function emit(out, data, human) {
|
|
120
|
+
if (out.json) {
|
|
121
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
122
|
+
`);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (human) {
|
|
126
|
+
human(out);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
130
|
+
`);
|
|
131
|
+
}
|
|
132
|
+
function note(out, message) {
|
|
133
|
+
if (out.json) return;
|
|
134
|
+
process.stderr.write(`${message}
|
|
135
|
+
`);
|
|
136
|
+
}
|
|
137
|
+
var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
|
|
138
|
+
function spinner(out, text) {
|
|
139
|
+
if (out.json || !process.stderr.isTTY) {
|
|
140
|
+
return { update: () => {
|
|
141
|
+
}, stop: () => {
|
|
142
|
+
} };
|
|
143
|
+
}
|
|
144
|
+
let current = text;
|
|
145
|
+
let frame = 0;
|
|
146
|
+
const render = () => {
|
|
147
|
+
process.stderr.write(`\r\x1B[2K${fmt.cyan(out, FRAMES[frame % FRAMES.length])} ${current}`);
|
|
148
|
+
frame++;
|
|
149
|
+
};
|
|
150
|
+
render();
|
|
151
|
+
const timer = setInterval(render, 90);
|
|
152
|
+
timer.unref();
|
|
153
|
+
return {
|
|
154
|
+
update(next) {
|
|
155
|
+
current = next;
|
|
156
|
+
},
|
|
157
|
+
stop(finalText) {
|
|
158
|
+
clearInterval(timer);
|
|
159
|
+
process.stderr.write("\r\x1B[2K");
|
|
160
|
+
if (finalText) process.stderr.write(`${finalText}
|
|
161
|
+
`);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function kv(out, rows) {
|
|
166
|
+
const width = Math.max(...rows.map(([k]) => k.length));
|
|
167
|
+
for (const [k, v] of rows) {
|
|
168
|
+
if (v === void 0 || v === null || v === "") continue;
|
|
169
|
+
process.stdout.write(`${fmt.dim(out, k.padEnd(width))} ${String(v)}
|
|
170
|
+
`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function table(out, headers, rows) {
|
|
174
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
175
|
+
const line = (cells, dim = false) => {
|
|
176
|
+
const s = cells.map((c, i) => c.padEnd(widths[i])).join(" ");
|
|
177
|
+
process.stdout.write(`${dim ? fmt.dim(out, s) : s}
|
|
178
|
+
`);
|
|
179
|
+
};
|
|
180
|
+
line(headers, true);
|
|
181
|
+
for (const row of rows) line(row);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// src/cli/command-path.ts
|
|
185
|
+
function buildCommandPath(cmd) {
|
|
186
|
+
const parts = [];
|
|
187
|
+
let c = cmd;
|
|
188
|
+
while (c && c.parent) {
|
|
189
|
+
parts.unshift(c.name());
|
|
190
|
+
c = c.parent;
|
|
191
|
+
}
|
|
192
|
+
return parts.join(" ") || "help";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// src/core/config.ts
|
|
196
|
+
import fs2 from "fs";
|
|
197
|
+
import path from "path";
|
|
198
|
+
import os from "os";
|
|
199
|
+
import crypto from "crypto";
|
|
200
|
+
var DEFAULT_BASE_URL = "https://app.videodraft.ai";
|
|
201
|
+
function configDir(env = process.env) {
|
|
202
|
+
if (env.VIDEODRAFT_CONFIG_DIR) return env.VIDEODRAFT_CONFIG_DIR;
|
|
203
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
204
|
+
return path.join(xdg && xdg.trim() !== "" ? xdg : path.join(os.homedir(), ".config"), "videodraft");
|
|
205
|
+
}
|
|
206
|
+
function configPath(env = process.env) {
|
|
207
|
+
return path.join(configDir(env), "config.json");
|
|
208
|
+
}
|
|
209
|
+
function emptyConfig() {
|
|
210
|
+
return { version: 1, active_profile: "default", profiles: {} };
|
|
211
|
+
}
|
|
212
|
+
function readConfig(env = process.env) {
|
|
213
|
+
try {
|
|
214
|
+
const raw = fs2.readFileSync(configPath(env), "utf8");
|
|
215
|
+
const parsed = JSON.parse(raw);
|
|
216
|
+
if (!parsed || typeof parsed !== "object" || !parsed.profiles) return emptyConfig();
|
|
217
|
+
return parsed;
|
|
218
|
+
} catch {
|
|
219
|
+
return emptyConfig();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function writeConfig(config, env = process.env) {
|
|
223
|
+
const dir = configDir(env);
|
|
224
|
+
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
225
|
+
const file = configPath(env);
|
|
226
|
+
const tmp = path.join(dir, `.config.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`);
|
|
227
|
+
fs2.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}
|
|
228
|
+
`, { mode: 384 });
|
|
229
|
+
fs2.renameSync(tmp, file);
|
|
230
|
+
try {
|
|
231
|
+
fs2.chmodSync(file, 384);
|
|
232
|
+
} catch {
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
function updateConfig(mutate, env = process.env) {
|
|
236
|
+
const config = readConfig(env);
|
|
237
|
+
mutate(config);
|
|
238
|
+
writeConfig(config, env);
|
|
239
|
+
return config;
|
|
240
|
+
}
|
|
241
|
+
function getProfile(name, env = process.env) {
|
|
242
|
+
const config = readConfig(env);
|
|
243
|
+
const profileName = name ?? config.active_profile ?? "default";
|
|
244
|
+
return { name: profileName, profile: config.profiles[profileName], config };
|
|
245
|
+
}
|
|
246
|
+
function anonymousId(env = process.env) {
|
|
247
|
+
const config = readConfig(env);
|
|
248
|
+
if (config.anonymous_id) return config.anonymous_id;
|
|
249
|
+
const id = crypto.randomUUID();
|
|
250
|
+
updateConfig((c) => {
|
|
251
|
+
c.anonymous_id = id;
|
|
252
|
+
}, env);
|
|
253
|
+
return id;
|
|
254
|
+
}
|
|
255
|
+
var LOCK_STALE_MS = 3e4;
|
|
256
|
+
async function withLock(lockName, fn, env = process.env) {
|
|
257
|
+
const dir = configDir(env);
|
|
258
|
+
fs2.mkdirSync(dir, { recursive: true, mode: 448 });
|
|
259
|
+
const lockPath = path.join(dir, `${lockName}.lock`);
|
|
260
|
+
const deadline = Date.now() + 15e3;
|
|
261
|
+
for (; ; ) {
|
|
262
|
+
try {
|
|
263
|
+
const fd = fs2.openSync(lockPath, "wx");
|
|
264
|
+
fs2.writeSync(fd, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
265
|
+
fs2.closeSync(fd);
|
|
266
|
+
break;
|
|
267
|
+
} catch {
|
|
268
|
+
try {
|
|
269
|
+
const stat = fs2.statSync(lockPath);
|
|
270
|
+
if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
|
|
271
|
+
fs2.rmSync(lockPath, { force: true });
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (Date.now() > deadline) {
|
|
278
|
+
throw new Error(`Timed out waiting for lock ${lockName} (${lockPath})`);
|
|
279
|
+
}
|
|
280
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return await fn();
|
|
285
|
+
} finally {
|
|
286
|
+
fs2.rmSync(lockPath, { force: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// src/cli/telemetry.ts
|
|
291
|
+
init_version();
|
|
292
|
+
var POSTHOG_KEY = process.env.VIDEODRAFT_POSTHOG_KEY ?? "";
|
|
293
|
+
var POSTHOG_HOST = process.env.VIDEODRAFT_POSTHOG_HOST ?? "https://us.i.posthog.com";
|
|
294
|
+
var pending = null;
|
|
295
|
+
function telemetryEnabled(env = process.env) {
|
|
296
|
+
if (!POSTHOG_KEY) return false;
|
|
297
|
+
if (env.DO_NOT_TRACK && env.DO_NOT_TRACK !== "0") return false;
|
|
298
|
+
const flag = env.VIDEODRAFT_TELEMETRY;
|
|
299
|
+
if (flag && ["0", "false", "off"].includes(flag.toLowerCase())) return false;
|
|
300
|
+
const config = readConfig(env);
|
|
301
|
+
if (config.telemetry === false) return false;
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
function maybePrintFirstRunNotice(env = process.env) {
|
|
305
|
+
if (!telemetryEnabled(env)) return;
|
|
306
|
+
const config = readConfig(env);
|
|
307
|
+
if (config.anonymous_id) return;
|
|
308
|
+
process.stderr.write(
|
|
309
|
+
"videodraft collects anonymous usage data (command name, version, OS \u2014 never prompts or content).\nDisable anytime: videodraft config set telemetry false (or DO_NOT_TRACK=1)\n\n"
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
function capture(event, properties = {}) {
|
|
313
|
+
if (!telemetryEnabled()) return;
|
|
314
|
+
const body = JSON.stringify({
|
|
315
|
+
api_key: POSTHOG_KEY,
|
|
316
|
+
event,
|
|
317
|
+
distinct_id: anonymousId(),
|
|
318
|
+
properties: {
|
|
319
|
+
cli_version: VERSION,
|
|
320
|
+
os: process.platform,
|
|
321
|
+
arch: process.arch,
|
|
322
|
+
node_major: Number(process.versions.node.split(".")[0]),
|
|
323
|
+
is_ci: Boolean(process.env.CI),
|
|
324
|
+
...properties
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
pending = fetch(`${POSTHOG_HOST}/capture/`, {
|
|
328
|
+
method: "POST",
|
|
329
|
+
headers: { "content-type": "application/json" },
|
|
330
|
+
body,
|
|
331
|
+
signal: AbortSignal.timeout(1500)
|
|
332
|
+
}).catch(() => void 0);
|
|
333
|
+
}
|
|
334
|
+
async function shutdown() {
|
|
335
|
+
if (!pending) return;
|
|
336
|
+
await Promise.race([pending, new Promise((r) => setTimeout(r, 400))]);
|
|
337
|
+
}
|
|
338
|
+
function setTelemetryPreference(enabled) {
|
|
339
|
+
updateConfig((c) => {
|
|
340
|
+
c.telemetry = enabled;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/cli/update-check.ts
|
|
345
|
+
init_version();
|
|
346
|
+
var CHECK_EVERY_MS = 24 * 60 * 60 * 1e3;
|
|
347
|
+
function isNewer(latest, current) {
|
|
348
|
+
const a = latest.split(".").map(Number);
|
|
349
|
+
const b = current.split(".").map(Number);
|
|
350
|
+
for (let i = 0; i < 3; i++) {
|
|
351
|
+
const x = a[i] ?? 0;
|
|
352
|
+
const y = b[i] ?? 0;
|
|
353
|
+
if (x !== y) return x > y;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
async function maybeCheckForUpdate() {
|
|
358
|
+
try {
|
|
359
|
+
if (!process.stderr.isTTY) return;
|
|
360
|
+
if (process.env.CI || process.env.DO_NOT_TRACK) return;
|
|
361
|
+
if (process.env.VIDEODRAFT_NO_UPDATE_CHECK) return;
|
|
362
|
+
const config = readConfig();
|
|
363
|
+
if (config.last_update_check && Date.now() - config.last_update_check < CHECK_EVERY_MS) return;
|
|
364
|
+
updateConfig((c) => {
|
|
365
|
+
c.last_update_check = Date.now();
|
|
366
|
+
});
|
|
367
|
+
const res = await fetch("https://registry.npmjs.org/videodraft/latest", {
|
|
368
|
+
signal: AbortSignal.timeout(1500),
|
|
369
|
+
headers: { accept: "application/json" }
|
|
370
|
+
});
|
|
371
|
+
if (!res.ok) return;
|
|
372
|
+
const body = await res.json();
|
|
373
|
+
const latest = body?.version;
|
|
374
|
+
if (typeof latest === "string" && isNewer(latest, VERSION)) {
|
|
375
|
+
process.stderr.write(
|
|
376
|
+
`
|
|
377
|
+
A new version of videodraft is available: ${VERSION} \u2192 ${latest}
|
|
378
|
+
npm install -g videodraft
|
|
379
|
+
|
|
380
|
+
`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
} catch {
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// src/commands/auth.ts
|
|
388
|
+
import { spawn } from "child_process";
|
|
389
|
+
|
|
390
|
+
// src/auth/oauth.ts
|
|
391
|
+
import http from "http";
|
|
392
|
+
import crypto2 from "crypto";
|
|
393
|
+
var STATIC_CLI_CLIENT_ID = "vd_client_cli";
|
|
394
|
+
var OAUTH_SCOPE = "mcp";
|
|
395
|
+
function b64url(buf) {
|
|
396
|
+
return buf.toString("base64url");
|
|
397
|
+
}
|
|
398
|
+
function createPkcePair() {
|
|
399
|
+
const verifier = b64url(crypto2.randomBytes(32));
|
|
400
|
+
const challenge = b64url(crypto2.createHash("sha256").update(verifier).digest());
|
|
401
|
+
return { verifier, challenge };
|
|
402
|
+
}
|
|
403
|
+
function buildAuthorizeUrl(params) {
|
|
404
|
+
const url = new URL("/api/oauth/authorize", params.baseUrl);
|
|
405
|
+
url.searchParams.set("response_type", "code");
|
|
406
|
+
url.searchParams.set("client_id", params.clientId);
|
|
407
|
+
url.searchParams.set("redirect_uri", params.redirectUri);
|
|
408
|
+
url.searchParams.set("scope", OAUTH_SCOPE);
|
|
409
|
+
url.searchParams.set("state", params.state);
|
|
410
|
+
url.searchParams.set("code_challenge", params.codeChallenge);
|
|
411
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
412
|
+
url.searchParams.set("resource", `${params.baseUrl}/api/mcp`);
|
|
413
|
+
return url.toString();
|
|
414
|
+
}
|
|
415
|
+
async function probeClientId(baseUrl, clientId, redirectUri, fetchImpl = fetch) {
|
|
416
|
+
const { challenge } = createPkcePair();
|
|
417
|
+
const url = buildAuthorizeUrl({
|
|
418
|
+
baseUrl,
|
|
419
|
+
clientId,
|
|
420
|
+
redirectUri,
|
|
421
|
+
state: "probe",
|
|
422
|
+
codeChallenge: challenge
|
|
423
|
+
});
|
|
424
|
+
const res = await fetchImpl(url, { redirect: "manual", signal: AbortSignal.timeout(15e3) });
|
|
425
|
+
return res.status >= 300 && res.status < 400;
|
|
426
|
+
}
|
|
427
|
+
async function registerCliClient(baseUrl, fetchImpl = fetch) {
|
|
428
|
+
const res = await fetchImpl(new URL("/api/oauth/register", baseUrl), {
|
|
429
|
+
method: "POST",
|
|
430
|
+
headers: { "content-type": "application/json" },
|
|
431
|
+
body: JSON.stringify({
|
|
432
|
+
client_name: "VideoDraft CLI",
|
|
433
|
+
redirect_uris: ["http://127.0.0.1/callback"],
|
|
434
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
435
|
+
response_types: ["code"],
|
|
436
|
+
token_endpoint_auth_method: "none",
|
|
437
|
+
scope: OAUTH_SCOPE
|
|
438
|
+
}),
|
|
439
|
+
signal: AbortSignal.timeout(15e3)
|
|
440
|
+
});
|
|
441
|
+
const body = await res.json().catch(() => ({}));
|
|
442
|
+
if (!res.ok || !body?.client_id) {
|
|
443
|
+
throw new CliError(
|
|
444
|
+
`OAuth client registration failed: ${body?.error_description ?? body?.error ?? res.status}`
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
return body.client_id;
|
|
448
|
+
}
|
|
449
|
+
async function authorizeViaLoopback(params) {
|
|
450
|
+
const { verifier, challenge } = createPkcePair();
|
|
451
|
+
const state = b64url(crypto2.randomBytes(16));
|
|
452
|
+
const server = http.createServer();
|
|
453
|
+
await new Promise((resolve, reject) => {
|
|
454
|
+
server.once("error", reject);
|
|
455
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
456
|
+
});
|
|
457
|
+
const port = server.address().port;
|
|
458
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
459
|
+
const codePromise = new Promise((resolve, reject) => {
|
|
460
|
+
const timeout = setTimeout(
|
|
461
|
+
() => reject(new CliError("Timed out waiting for the browser login (5 minutes).")),
|
|
462
|
+
params.timeoutMs ?? 3e5
|
|
463
|
+
);
|
|
464
|
+
timeout.unref();
|
|
465
|
+
server.on("request", (req, res) => {
|
|
466
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
467
|
+
if (url.pathname !== "/callback") {
|
|
468
|
+
res.writeHead(404).end();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
const err = url.searchParams.get("error");
|
|
472
|
+
const code = url.searchParams.get("code");
|
|
473
|
+
const gotState = url.searchParams.get("state");
|
|
474
|
+
const page = (title, body) => `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title></head><body style="font-family:system-ui,sans-serif;max-width:480px;margin:80px auto;padding:0 20px;text-align:center"><h1 style="font-size:20px">${title}</h1><p style="color:#555">${body}</p></body></html>`;
|
|
475
|
+
if (err) {
|
|
476
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
477
|
+
res.end(page("Login failed", `${err}. You can close this tab and return to the terminal.`));
|
|
478
|
+
clearTimeout(timeout);
|
|
479
|
+
reject(new CliError(`Authorization failed: ${err}`));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (!code || gotState !== state) {
|
|
483
|
+
res.writeHead(400, { "content-type": "text/html; charset=utf-8" });
|
|
484
|
+
res.end(page("Login failed", "Missing code or state mismatch. Close this tab and retry."));
|
|
485
|
+
clearTimeout(timeout);
|
|
486
|
+
reject(new CliError("Authorization response missing code or state mismatch."));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
490
|
+
res.end(page("You're logged in to VideoDraft", "You can close this tab and return to the terminal."));
|
|
491
|
+
clearTimeout(timeout);
|
|
492
|
+
resolve(code);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
codePromise.catch(() => {
|
|
496
|
+
});
|
|
497
|
+
try {
|
|
498
|
+
const authorizeUrl = buildAuthorizeUrl({
|
|
499
|
+
baseUrl: params.baseUrl,
|
|
500
|
+
clientId: params.clientId,
|
|
501
|
+
redirectUri,
|
|
502
|
+
state,
|
|
503
|
+
codeChallenge: challenge
|
|
504
|
+
});
|
|
505
|
+
await params.onAuthorizeUrl(authorizeUrl);
|
|
506
|
+
const code = await codePromise;
|
|
507
|
+
return { code, redirectUri, verifier };
|
|
508
|
+
} finally {
|
|
509
|
+
server.close();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
async function exchangeCode(params) {
|
|
513
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
514
|
+
const res = await fetchImpl(new URL("/api/oauth/token", params.baseUrl), {
|
|
515
|
+
method: "POST",
|
|
516
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
517
|
+
body: new URLSearchParams({
|
|
518
|
+
grant_type: "authorization_code",
|
|
519
|
+
code: params.code,
|
|
520
|
+
redirect_uri: params.redirectUri,
|
|
521
|
+
client_id: params.clientId,
|
|
522
|
+
code_verifier: params.verifier
|
|
523
|
+
}).toString(),
|
|
524
|
+
signal: AbortSignal.timeout(3e4)
|
|
525
|
+
});
|
|
526
|
+
const body = await res.json().catch(() => ({}));
|
|
527
|
+
if (!res.ok || !body?.access_token) {
|
|
528
|
+
throw new CliError(
|
|
529
|
+
`Token exchange failed: ${body?.error_description ?? body?.error ?? `HTTP ${res.status}`}`
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
return body;
|
|
533
|
+
}
|
|
534
|
+
async function refreshAccessToken(params) {
|
|
535
|
+
const fetchImpl = params.fetchImpl ?? fetch;
|
|
536
|
+
const res = await fetchImpl(new URL("/api/oauth/token", params.baseUrl), {
|
|
537
|
+
method: "POST",
|
|
538
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
539
|
+
body: new URLSearchParams({
|
|
540
|
+
grant_type: "refresh_token",
|
|
541
|
+
refresh_token: params.refreshToken,
|
|
542
|
+
client_id: params.clientId
|
|
543
|
+
}).toString(),
|
|
544
|
+
signal: AbortSignal.timeout(3e4)
|
|
545
|
+
});
|
|
546
|
+
if (!res.ok) return null;
|
|
547
|
+
const body = await res.json().catch(() => null);
|
|
548
|
+
return body?.access_token ? body : null;
|
|
549
|
+
}
|
|
550
|
+
async function revokeToken(baseUrl, token, fetchImpl = fetch) {
|
|
551
|
+
try {
|
|
552
|
+
await fetchImpl(new URL("/api/oauth/revoke", baseUrl), {
|
|
553
|
+
method: "POST",
|
|
554
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
555
|
+
body: new URLSearchParams({ token }).toString(),
|
|
556
|
+
signal: AbortSignal.timeout(1e4)
|
|
557
|
+
});
|
|
558
|
+
} catch {
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/core/rpc.ts
|
|
563
|
+
var VideoDraftClient = class {
|
|
564
|
+
baseUrl;
|
|
565
|
+
tokenProvider;
|
|
566
|
+
fetchImpl;
|
|
567
|
+
userAgent;
|
|
568
|
+
requestTimeoutMs;
|
|
569
|
+
nextId = 1;
|
|
570
|
+
constructor(options) {
|
|
571
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
572
|
+
this.tokenProvider = options.tokenProvider;
|
|
573
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
574
|
+
this.userAgent = options.userAgent ?? "videodraft-cli";
|
|
575
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 3e5;
|
|
576
|
+
}
|
|
577
|
+
get endpoint() {
|
|
578
|
+
return `${this.baseUrl}/api/mcp`;
|
|
579
|
+
}
|
|
580
|
+
async rpc(method, params) {
|
|
581
|
+
const token = await this.tokenProvider.getAccessToken();
|
|
582
|
+
let response = await this.post(method, params, token);
|
|
583
|
+
if (response.status === 401 && this.tokenProvider.onUnauthorized) {
|
|
584
|
+
const fresh = await this.tokenProvider.onUnauthorized();
|
|
585
|
+
if (fresh) response = await this.post(method, params, fresh);
|
|
586
|
+
}
|
|
587
|
+
if (response.status === 401) {
|
|
588
|
+
throw new AuthError("Token is invalid, expired, or revoked.");
|
|
589
|
+
}
|
|
590
|
+
if (!response.ok) {
|
|
591
|
+
let detail = "";
|
|
592
|
+
try {
|
|
593
|
+
const body2 = await response.json();
|
|
594
|
+
detail = body2?.error?.message ?? body2?.error ?? "";
|
|
595
|
+
} catch {
|
|
596
|
+
}
|
|
597
|
+
throw new RpcError(response.status, detail || `HTTP ${response.status} from ${this.endpoint}`);
|
|
598
|
+
}
|
|
599
|
+
let body;
|
|
600
|
+
try {
|
|
601
|
+
body = await response.json();
|
|
602
|
+
} catch {
|
|
603
|
+
throw new RpcError(0, `Server returned a non-JSON response (HTTP ${response.status}).`);
|
|
604
|
+
}
|
|
605
|
+
if (body?.error) {
|
|
606
|
+
throw new RpcError(body.error.code ?? -1, body.error.message ?? "Unknown RPC error", body.error.data);
|
|
607
|
+
}
|
|
608
|
+
return body?.result;
|
|
609
|
+
}
|
|
610
|
+
post(method, params, token) {
|
|
611
|
+
return this.fetchImpl(this.endpoint, {
|
|
612
|
+
method: "POST",
|
|
613
|
+
headers: {
|
|
614
|
+
"content-type": "application/json",
|
|
615
|
+
authorization: `Bearer ${token}`,
|
|
616
|
+
"user-agent": this.userAgent
|
|
617
|
+
},
|
|
618
|
+
body: JSON.stringify({
|
|
619
|
+
jsonrpc: "2.0",
|
|
620
|
+
id: this.nextId++,
|
|
621
|
+
method,
|
|
622
|
+
...params === void 0 ? {} : { params }
|
|
623
|
+
}),
|
|
624
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Batch several JSON-RPC requests into ONE HTTP round trip (the server
|
|
629
|
+
* implements JSON-RPC 2.0 batching). With N concurrent jobs this turns N
|
|
630
|
+
* polling requests per tick into 1 — the difference between 50 waiting CLIs
|
|
631
|
+
* generating ~17 req/s and ~0.3 req/s against /api/mcp.
|
|
632
|
+
* Results are matched by id (order-independent per spec).
|
|
633
|
+
*/
|
|
634
|
+
async rpcBatch(calls) {
|
|
635
|
+
if (calls.length === 0) return [];
|
|
636
|
+
const token = await this.tokenProvider.getAccessToken();
|
|
637
|
+
const ids = calls.map(() => this.nextId++);
|
|
638
|
+
const payload = calls.map((c, i) => ({
|
|
639
|
+
jsonrpc: "2.0",
|
|
640
|
+
id: ids[i],
|
|
641
|
+
method: c.method,
|
|
642
|
+
...c.params === void 0 ? {} : { params: c.params }
|
|
643
|
+
}));
|
|
644
|
+
const post = (bearer) => this.fetchImpl(this.endpoint, {
|
|
645
|
+
method: "POST",
|
|
646
|
+
headers: {
|
|
647
|
+
"content-type": "application/json",
|
|
648
|
+
authorization: `Bearer ${bearer}`,
|
|
649
|
+
"user-agent": this.userAgent
|
|
650
|
+
},
|
|
651
|
+
body: JSON.stringify(payload),
|
|
652
|
+
signal: AbortSignal.timeout(this.requestTimeoutMs)
|
|
653
|
+
});
|
|
654
|
+
let response = await post(token);
|
|
655
|
+
if (response.status === 401 && this.tokenProvider.onUnauthorized) {
|
|
656
|
+
const fresh = await this.tokenProvider.onUnauthorized();
|
|
657
|
+
if (fresh) response = await post(fresh);
|
|
658
|
+
}
|
|
659
|
+
if (response.status === 401) throw new AuthError("Token is invalid, expired, or revoked.");
|
|
660
|
+
if (!response.ok) throw new RpcError(response.status, `HTTP ${response.status} from ${this.endpoint}`);
|
|
661
|
+
let body;
|
|
662
|
+
try {
|
|
663
|
+
body = await response.json();
|
|
664
|
+
} catch {
|
|
665
|
+
throw new RpcError(0, `Server returned a non-JSON response (HTTP ${response.status}).`);
|
|
666
|
+
}
|
|
667
|
+
const responses = Array.isArray(body) ? body : [body];
|
|
668
|
+
const byId = new Map(responses.map((r) => [r?.id, r]));
|
|
669
|
+
return ids.map((id) => {
|
|
670
|
+
const r = byId.get(id);
|
|
671
|
+
if (!r) throw new RpcError(-1, `Batch response missing id ${id}`);
|
|
672
|
+
if (r.error) throw new RpcError(r.error.code ?? -1, r.error.message ?? "Unknown RPC error", r.error.data);
|
|
673
|
+
return r.result;
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Call several tools in one batched HTTP request. Per-item failures come
|
|
678
|
+
* back as { ok:false, error } instead of throwing, so one failed job can't
|
|
679
|
+
* abort polling of its siblings.
|
|
680
|
+
*/
|
|
681
|
+
async callToolBatch(calls) {
|
|
682
|
+
const results = await this.rpcBatch(
|
|
683
|
+
calls.map((c) => ({ method: "tools/call", params: { name: c.name, arguments: c.args ?? {} } }))
|
|
684
|
+
);
|
|
685
|
+
return results.map((result) => {
|
|
686
|
+
const text = result?.content?.[0]?.text ?? "";
|
|
687
|
+
if (result?.isError) {
|
|
688
|
+
return { ok: false, error: text.replace(/^Error:\s*/, "") || "tool failed" };
|
|
689
|
+
}
|
|
690
|
+
try {
|
|
691
|
+
return { ok: true, result: JSON.parse(text) };
|
|
692
|
+
} catch {
|
|
693
|
+
return { ok: true, result: text };
|
|
694
|
+
}
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Call an MCP tool and return its parsed result.
|
|
699
|
+
* The server wraps results as { content: [{type:"text", text}], isError } —
|
|
700
|
+
* text is JSON for successes and "Error: ..." for tool failures.
|
|
701
|
+
*/
|
|
702
|
+
async callTool(name, args = {}) {
|
|
703
|
+
const result = await this.rpc(
|
|
704
|
+
"tools/call",
|
|
705
|
+
{ name, arguments: args }
|
|
706
|
+
);
|
|
707
|
+
const text = result?.content?.[0]?.text ?? "";
|
|
708
|
+
if (result?.isError) {
|
|
709
|
+
throw new ToolError(name, text.replace(/^Error:\s*/, "") || `Tool ${name} failed`);
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
return JSON.parse(text);
|
|
713
|
+
} catch {
|
|
714
|
+
return text;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
async listTools() {
|
|
718
|
+
const result = await this.rpc("tools/list");
|
|
719
|
+
return result?.tools ?? [];
|
|
720
|
+
}
|
|
721
|
+
async ping() {
|
|
722
|
+
await this.rpc("ping");
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// src/auth/token-provider.ts
|
|
727
|
+
var EXPIRY_SKEW_MS = 6e4;
|
|
728
|
+
function isExpiring(profile) {
|
|
729
|
+
if (!profile.expires_at) return false;
|
|
730
|
+
return new Date(profile.expires_at).getTime() - Date.now() < EXPIRY_SKEW_MS;
|
|
731
|
+
}
|
|
732
|
+
function saveRotatedTokens(profileName, tokens, env) {
|
|
733
|
+
updateConfig((config) => {
|
|
734
|
+
const profile = config.profiles[profileName];
|
|
735
|
+
if (!profile) return;
|
|
736
|
+
profile.access_token = tokens.access_token;
|
|
737
|
+
profile.refresh_token = tokens.refresh_token;
|
|
738
|
+
profile.expires_at = new Date(Date.now() + tokens.expires_in * 1e3).toISOString();
|
|
739
|
+
}, env);
|
|
740
|
+
}
|
|
741
|
+
async function refreshUnderLock(profileName, env) {
|
|
742
|
+
return withLock(
|
|
743
|
+
"oauth-refresh",
|
|
744
|
+
async () => {
|
|
745
|
+
const { profile } = getProfile(profileName, env);
|
|
746
|
+
if (!profile) return null;
|
|
747
|
+
if (profile.auth_kind !== "oauth") return profile.access_token;
|
|
748
|
+
if (!isExpiring(profile)) return profile.access_token;
|
|
749
|
+
if (!profile.refresh_token || !profile.client_id) return null;
|
|
750
|
+
const tokens = await refreshAccessToken({
|
|
751
|
+
baseUrl: profile.base_url || DEFAULT_BASE_URL,
|
|
752
|
+
clientId: profile.client_id,
|
|
753
|
+
refreshToken: profile.refresh_token
|
|
754
|
+
});
|
|
755
|
+
if (!tokens) return null;
|
|
756
|
+
saveRotatedTokens(profileName, tokens, env);
|
|
757
|
+
return tokens.access_token;
|
|
758
|
+
},
|
|
759
|
+
env
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
function profileTokenProvider(profileName, env = process.env) {
|
|
763
|
+
return {
|
|
764
|
+
async getAccessToken() {
|
|
765
|
+
const { profile } = getProfile(profileName, env);
|
|
766
|
+
if (!profile?.access_token) throw new AuthError();
|
|
767
|
+
if (profile.auth_kind === "oauth" && isExpiring(profile)) {
|
|
768
|
+
const fresh = await refreshUnderLock(profileName, env);
|
|
769
|
+
if (!fresh) throw new AuthError("Your session expired and could not be refreshed.");
|
|
770
|
+
return fresh;
|
|
771
|
+
}
|
|
772
|
+
return profile.access_token;
|
|
773
|
+
},
|
|
774
|
+
async onUnauthorized() {
|
|
775
|
+
const { profile } = getProfile(profileName, env);
|
|
776
|
+
if (profile?.auth_kind !== "oauth") return null;
|
|
777
|
+
updateConfig((config) => {
|
|
778
|
+
const p = config.profiles[profileName];
|
|
779
|
+
if (p) p.expires_at = (/* @__PURE__ */ new Date(0)).toISOString();
|
|
780
|
+
}, env);
|
|
781
|
+
return refreshUnderLock(profileName, env);
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
function resolveAuth(options) {
|
|
786
|
+
const env = options.env ?? process.env;
|
|
787
|
+
const envBase = env.VIDEODRAFT_BASE_URL;
|
|
788
|
+
if (options.token) {
|
|
789
|
+
return {
|
|
790
|
+
tokenProvider: { getAccessToken: async () => options.token },
|
|
791
|
+
baseUrl: options.baseUrl ?? envBase ?? DEFAULT_BASE_URL,
|
|
792
|
+
source: "flag"
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
if (env.VIDEODRAFT_API_KEY) {
|
|
796
|
+
return {
|
|
797
|
+
tokenProvider: { getAccessToken: async () => env.VIDEODRAFT_API_KEY },
|
|
798
|
+
baseUrl: options.baseUrl ?? envBase ?? DEFAULT_BASE_URL,
|
|
799
|
+
source: "env"
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
const { name, profile } = getProfile(options.profile, env);
|
|
803
|
+
return {
|
|
804
|
+
tokenProvider: profileTokenProvider(name, env),
|
|
805
|
+
baseUrl: options.baseUrl ?? envBase ?? profile?.base_url ?? DEFAULT_BASE_URL,
|
|
806
|
+
source: "profile",
|
|
807
|
+
profileName: name
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// src/cli/context.ts
|
|
812
|
+
init_version();
|
|
813
|
+
function parseDuration(value, fallbackMs) {
|
|
814
|
+
if (!value) return fallbackMs;
|
|
815
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h)?$/.exec(value.trim());
|
|
816
|
+
if (!m) return fallbackMs;
|
|
817
|
+
const n = Number(m[1]);
|
|
818
|
+
switch (m[2]) {
|
|
819
|
+
case "ms":
|
|
820
|
+
return n;
|
|
821
|
+
case "m":
|
|
822
|
+
return n * 6e4;
|
|
823
|
+
case "h":
|
|
824
|
+
return n * 36e5;
|
|
825
|
+
default:
|
|
826
|
+
return n * 1e3;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function buildContext(command) {
|
|
830
|
+
const flags = command.optsWithGlobals();
|
|
831
|
+
const out = makeOutput({ json: flags.json, color: flags.color });
|
|
832
|
+
const auth = resolveAuth({
|
|
833
|
+
token: flags.token,
|
|
834
|
+
baseUrl: flags.baseUrl,
|
|
835
|
+
profile: flags.profile
|
|
836
|
+
});
|
|
837
|
+
const client = new VideoDraftClient({
|
|
838
|
+
tokenProvider: auth.tokenProvider,
|
|
839
|
+
baseUrl: auth.baseUrl,
|
|
840
|
+
userAgent: `videodraft-cli/${VERSION}`
|
|
841
|
+
});
|
|
842
|
+
return {
|
|
843
|
+
client,
|
|
844
|
+
out,
|
|
845
|
+
flags,
|
|
846
|
+
baseUrl: auth.baseUrl,
|
|
847
|
+
profileName: auth.profileName,
|
|
848
|
+
intervalMs: parseDuration(flags.waitInterval, 3e3),
|
|
849
|
+
timeoutMs: parseDuration(flags.waitTimeout, 6e5),
|
|
850
|
+
adaptive: flags.waitInterval === void 0
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
function collect(value, previous = []) {
|
|
854
|
+
return [...previous, value];
|
|
855
|
+
}
|
|
856
|
+
function compact(obj) {
|
|
857
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// src/commands/auth.ts
|
|
861
|
+
function openBrowser(url) {
|
|
862
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
863
|
+
try {
|
|
864
|
+
const child = spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
|
|
865
|
+
child.unref();
|
|
866
|
+
} catch {
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
async function readStdin() {
|
|
870
|
+
const chunks = [];
|
|
871
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
872
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
873
|
+
}
|
|
874
|
+
function saveProfile(profileName, profile) {
|
|
875
|
+
updateConfig((config) => {
|
|
876
|
+
config.profiles[profileName] = profile;
|
|
877
|
+
config.active_profile = profileName;
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
function registerAuthCommands(program) {
|
|
881
|
+
program.command("login").description("Authenticate with VideoDraft (browser login; --token for a PAT)").option("--token <vd_mcp_token>", "use a personal access token from /mcp-keys instead of the browser").option("--with-token", "read a personal access token from stdin").option("--no-browser", "print the login URL instead of opening a browser").action(async function() {
|
|
882
|
+
const opts = this.opts();
|
|
883
|
+
const globals = this.optsWithGlobals();
|
|
884
|
+
const baseUrl = (globals.baseUrl ?? process.env.VIDEODRAFT_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
885
|
+
const profileName = globals.profile ?? "default";
|
|
886
|
+
const ctxOut = buildContext(this).out;
|
|
887
|
+
let pat = opts.token;
|
|
888
|
+
if (!pat && opts.withToken) pat = await readStdin();
|
|
889
|
+
if (pat) {
|
|
890
|
+
if (!pat.startsWith("vd_mcp_")) {
|
|
891
|
+
throw new CliError("That does not look like a VideoDraft token (expected vd_mcp_...).", EXIT.USAGE);
|
|
892
|
+
}
|
|
893
|
+
const client = new VideoDraftClient({
|
|
894
|
+
tokenProvider: { getAccessToken: async () => pat },
|
|
895
|
+
baseUrl
|
|
896
|
+
});
|
|
897
|
+
const me = await client.callTool("whoami");
|
|
898
|
+
saveProfile(profileName, { base_url: baseUrl, auth_kind: "pat", access_token: pat });
|
|
899
|
+
capture("cli_login", { method: "pat" });
|
|
900
|
+
emit(ctxOut, { ok: true, method: "pat", user: me }, (o) => {
|
|
901
|
+
note(o, fmt.green(o, `Logged in as ${me?.email ?? me?.user_id ?? "user"} (PAT, ${baseUrl})`));
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
const existing = getProfile(profileName).profile;
|
|
906
|
+
let clientId = existing?.client_id ?? process.env.VIDEODRAFT_OAUTH_CLIENT_ID ?? STATIC_CLI_CLIENT_ID;
|
|
907
|
+
const known = await probeClientId(baseUrl, clientId, "http://127.0.0.1:1/callback").catch(() => false);
|
|
908
|
+
if (!known) {
|
|
909
|
+
note(ctxOut, fmt.dim(ctxOut, `OAuth client ${clientId} not registered on ${baseUrl} \u2014 registering one...`));
|
|
910
|
+
clientId = await registerCliClient(baseUrl);
|
|
911
|
+
}
|
|
912
|
+
const spin = spinner(ctxOut, "Waiting for browser login\u2026");
|
|
913
|
+
try {
|
|
914
|
+
const { code, redirectUri, verifier } = await authorizeViaLoopback({
|
|
915
|
+
baseUrl,
|
|
916
|
+
clientId,
|
|
917
|
+
onAuthorizeUrl: (url) => {
|
|
918
|
+
if (opts.browser !== false) openBrowser(url);
|
|
919
|
+
note(ctxOut, `
|
|
920
|
+
${fmt.bold(ctxOut, "Open this URL to log in:")}
|
|
921
|
+
${url}
|
|
922
|
+
`);
|
|
923
|
+
}
|
|
924
|
+
});
|
|
925
|
+
spin.update("Exchanging authorization code\u2026");
|
|
926
|
+
const tokens = await exchangeCode({ baseUrl, clientId, code, redirectUri, verifier });
|
|
927
|
+
saveProfile(profileName, {
|
|
928
|
+
base_url: baseUrl,
|
|
929
|
+
auth_kind: "oauth",
|
|
930
|
+
access_token: tokens.access_token,
|
|
931
|
+
refresh_token: tokens.refresh_token,
|
|
932
|
+
expires_at: new Date(Date.now() + tokens.expires_in * 1e3).toISOString(),
|
|
933
|
+
client_id: clientId
|
|
934
|
+
});
|
|
935
|
+
spin.stop();
|
|
936
|
+
const client = new VideoDraftClient({
|
|
937
|
+
tokenProvider: { getAccessToken: async () => tokens.access_token },
|
|
938
|
+
baseUrl
|
|
939
|
+
});
|
|
940
|
+
const me = await client.callTool("whoami").catch(() => null);
|
|
941
|
+
capture("cli_login", { method: "oauth" });
|
|
942
|
+
emit(ctxOut, { ok: true, method: "oauth", user: me }, (o) => {
|
|
943
|
+
note(o, fmt.green(o, `Logged in as ${me?.email ?? "user"} (${baseUrl})`));
|
|
944
|
+
note(o, fmt.dim(o, `Tokens stored in ${process.env.VIDEODRAFT_CONFIG_DIR ?? "~/.config/videodraft"}/config.json`));
|
|
945
|
+
});
|
|
946
|
+
} catch (err) {
|
|
947
|
+
spin.stop();
|
|
948
|
+
throw err;
|
|
949
|
+
}
|
|
950
|
+
});
|
|
951
|
+
program.command("logout").description("Revoke the current OAuth grant (best-effort) and clear stored credentials").action(async function() {
|
|
952
|
+
const globals = this.optsWithGlobals();
|
|
953
|
+
const profileName = globals.profile ?? "default";
|
|
954
|
+
const ctxOut = buildContext(this).out;
|
|
955
|
+
const { profile } = getProfile(profileName);
|
|
956
|
+
if (!profile) {
|
|
957
|
+
emit(ctxOut, { ok: true, message: "No stored credentials." }, (o) => note(o, "No stored credentials."));
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (profile.auth_kind === "oauth") {
|
|
961
|
+
await revokeToken(profile.base_url, profile.access_token);
|
|
962
|
+
if (profile.refresh_token) await revokeToken(profile.base_url, profile.refresh_token);
|
|
963
|
+
}
|
|
964
|
+
updateConfig((config) => {
|
|
965
|
+
delete config.profiles[profileName];
|
|
966
|
+
});
|
|
967
|
+
capture("cli_logout");
|
|
968
|
+
emit(ctxOut, { ok: true }, (o) => {
|
|
969
|
+
note(o, fmt.green(o, "Logged out."));
|
|
970
|
+
if (profile.auth_kind === "pat") {
|
|
971
|
+
note(o, fmt.dim(o, "PATs are revoked from the web app: https://app.videodraft.ai/mcp-keys"));
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
});
|
|
975
|
+
program.command("whoami").description("Show the authenticated VideoDraft user").action(async function() {
|
|
976
|
+
const ctx = buildContext(this);
|
|
977
|
+
const me = await ctx.client.callTool("whoami");
|
|
978
|
+
emit(ctx.out, me, (o) => {
|
|
979
|
+
kv(o, [
|
|
980
|
+
["User", me?.email ?? me?.user_id],
|
|
981
|
+
["User id", me?.user_id ?? me?.id],
|
|
982
|
+
["Workspace", me?.workspace_id ?? me?.workspace ?? "personal"],
|
|
983
|
+
["Server", ctx.baseUrl]
|
|
984
|
+
]);
|
|
985
|
+
});
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// src/commands/account.ts
|
|
990
|
+
function registerAccountCommands(program) {
|
|
991
|
+
program.command("credits").description("Show your credit balance").action(async function() {
|
|
992
|
+
const ctx = buildContext(this);
|
|
993
|
+
const balance = await ctx.client.callTool("get_credits_balance");
|
|
994
|
+
emit(ctx.out, balance, (o) => {
|
|
995
|
+
kv(o, [
|
|
996
|
+
["Available credits", balance?.availableCredits],
|
|
997
|
+
["Monthly allowance", balance?.totalCreditsMonthly],
|
|
998
|
+
["Used this month", balance?.monthlyCreditsUsed],
|
|
999
|
+
["Bonus credits", balance?.bonusCredits],
|
|
1000
|
+
["Bonus expiry", balance?.bonusCreditsExpiry]
|
|
1001
|
+
]);
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
program.command("costs [model]").description("Show credit costs \u2014 pass a model id plus video settings for an exact estimate").option("--type <type>", "image | video").option("--duration <seconds>", "video duration in seconds").option("--resolution <res>", 'e.g. "720p", "1080p", "1K", "2K"').option("--quality <tier>", 'e.g. "standard", "pro", "fast"').option("--rendering-speed <tier>", 'image speed/cost tier, e.g. Ideogram V4 "Turbo"/"Balanced"/"Quality"').option("--audio", "include native model audio in the estimate").option("--no-audio", "exclude native model audio").option("--num <n>", "image batch size").action(async function(model) {
|
|
1005
|
+
const ctx = buildContext(this);
|
|
1006
|
+
const opts = this.opts();
|
|
1007
|
+
const result = await ctx.client.callTool(
|
|
1008
|
+
"get_model_costs",
|
|
1009
|
+
compact({
|
|
1010
|
+
model_id: model,
|
|
1011
|
+
type: opts.type,
|
|
1012
|
+
duration_seconds: opts.duration ? Number(opts.duration) : void 0,
|
|
1013
|
+
resolution: opts.resolution,
|
|
1014
|
+
quality: opts.quality,
|
|
1015
|
+
rendering_speed: opts.renderingSpeed,
|
|
1016
|
+
generate_audio: opts.audio,
|
|
1017
|
+
num_images: opts.num ? Number(opts.num) : void 0
|
|
1018
|
+
})
|
|
1019
|
+
);
|
|
1020
|
+
emit(ctx.out, result);
|
|
1021
|
+
});
|
|
1022
|
+
program.command("models [kind]").description("List available models: image | video | voices | styles (default: image + video)").action(async function(kind) {
|
|
1023
|
+
const ctx = buildContext(this);
|
|
1024
|
+
const wanted = kind ?? "all";
|
|
1025
|
+
const result = {};
|
|
1026
|
+
if (wanted === "image" || wanted === "all") {
|
|
1027
|
+
result.image = await ctx.client.callTool("list_available_image_models");
|
|
1028
|
+
}
|
|
1029
|
+
if (wanted === "video" || wanted === "all") {
|
|
1030
|
+
result.video = await ctx.client.callTool("list_available_video_models");
|
|
1031
|
+
}
|
|
1032
|
+
if (wanted === "voices") result.voices = await ctx.client.callTool("list_available_voices");
|
|
1033
|
+
if (wanted === "styles") result.styles = await ctx.client.callTool("list_available_styles");
|
|
1034
|
+
emit(ctx.out, result, (o) => {
|
|
1035
|
+
for (const [section, payload] of Object.entries(result)) {
|
|
1036
|
+
const models = Array.isArray(payload) ? payload : payload?.models ?? payload?.voices ?? payload?.styles ?? [];
|
|
1037
|
+
process.stdout.write(`
|
|
1038
|
+
${section.toUpperCase()}
|
|
1039
|
+
`);
|
|
1040
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
1041
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
1042
|
+
continue;
|
|
1043
|
+
}
|
|
1044
|
+
table(
|
|
1045
|
+
o,
|
|
1046
|
+
["id", "name", "cost"],
|
|
1047
|
+
models.map((m) => [
|
|
1048
|
+
String(m.id ?? m.model_id ?? m.voice_id ?? ""),
|
|
1049
|
+
String(m.name ?? "").slice(0, 40),
|
|
1050
|
+
String(m.credit_cost ?? m.cost ?? m.pricing?.summary ?? "")
|
|
1051
|
+
])
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
});
|
|
1056
|
+
program.command("workspaces").description("List your workspaces (the active one is bound to your token)").action(async function() {
|
|
1057
|
+
const ctx = buildContext(this);
|
|
1058
|
+
const result = await ctx.client.callTool("list_workspaces");
|
|
1059
|
+
const workspaces = result?.workspaces ?? result ?? [];
|
|
1060
|
+
emit(ctx.out, result, (o) => {
|
|
1061
|
+
table(
|
|
1062
|
+
o,
|
|
1063
|
+
["id", "name", "role", "active"],
|
|
1064
|
+
workspaces.map((w) => [
|
|
1065
|
+
String(w.id ?? ""),
|
|
1066
|
+
String(w.name ?? ""),
|
|
1067
|
+
String(w.role ?? ""),
|
|
1068
|
+
w.is_active || w.active ? "\u2713" : ""
|
|
1069
|
+
])
|
|
1070
|
+
);
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
const sessions = program.command("sessions").description("AI Studio sessions");
|
|
1074
|
+
sessions.command("list", { isDefault: true }).description("List AI Studio sessions").option("--project <id>", "filter to a project's sessions").action(async function() {
|
|
1075
|
+
const ctx = buildContext(this);
|
|
1076
|
+
const result = await ctx.client.callTool(
|
|
1077
|
+
"list_ai_studio_sessions",
|
|
1078
|
+
compact({ project_id: this.opts().project })
|
|
1079
|
+
);
|
|
1080
|
+
const rows = result?.sessions ?? result ?? [];
|
|
1081
|
+
emit(ctx.out, result, (o) => {
|
|
1082
|
+
if (!Array.isArray(rows)) return;
|
|
1083
|
+
table(
|
|
1084
|
+
o,
|
|
1085
|
+
["id", "name", "created"],
|
|
1086
|
+
rows.map((s) => [
|
|
1087
|
+
String(s.id ?? s.session_id ?? ""),
|
|
1088
|
+
String(s.name ?? "").slice(0, 40),
|
|
1089
|
+
String(s.created_at ?? s.createdAt ?? "").slice(0, 19)
|
|
1090
|
+
])
|
|
1091
|
+
);
|
|
1092
|
+
});
|
|
1093
|
+
});
|
|
1094
|
+
sessions.command("create <name>").description("Create an AI Studio session (reuse its id across standalone generations)").option("--project <id>", "attach the session to a project").action(async function(name) {
|
|
1095
|
+
const ctx = buildContext(this);
|
|
1096
|
+
const result = await ctx.client.callTool(
|
|
1097
|
+
"create_ai_studio_session",
|
|
1098
|
+
compact({ name, project_id: this.opts().project })
|
|
1099
|
+
);
|
|
1100
|
+
emit(ctx.out, result, (o) => {
|
|
1101
|
+
const id = result?.session_id ?? result?.id;
|
|
1102
|
+
process.stdout.write(`${id ?? JSON.stringify(result)}
|
|
1103
|
+
`);
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// src/commands/projects.ts
|
|
1109
|
+
import { spawn as spawn2 } from "child_process";
|
|
1110
|
+
function registerProjectCommands(program) {
|
|
1111
|
+
const projects = program.command("projects").description("List and manage projects");
|
|
1112
|
+
projects.command("list", { isDefault: true }).description("List projects").option("--limit <n>", "max projects (default 50)").option("--offset <n>", "pagination offset").option("--favorites", "only favorited projects").action(async function() {
|
|
1113
|
+
const ctx = buildContext(this);
|
|
1114
|
+
const opts = this.opts();
|
|
1115
|
+
const result = await ctx.client.callTool(
|
|
1116
|
+
"list_projects",
|
|
1117
|
+
compact({
|
|
1118
|
+
limit: opts.limit ? Number(opts.limit) : void 0,
|
|
1119
|
+
offset: opts.offset ? Number(opts.offset) : void 0,
|
|
1120
|
+
favorites_only: opts.favorites || void 0
|
|
1121
|
+
})
|
|
1122
|
+
);
|
|
1123
|
+
const rows = result?.projects ?? [];
|
|
1124
|
+
emit(ctx.out, result, (o) => {
|
|
1125
|
+
table(
|
|
1126
|
+
o,
|
|
1127
|
+
["id", "title", "status", "modified"],
|
|
1128
|
+
rows.map((p) => [
|
|
1129
|
+
String(p.id ?? ""),
|
|
1130
|
+
String(p.title ?? "Untitled").slice(0, 44),
|
|
1131
|
+
String(p.status ?? ""),
|
|
1132
|
+
String(p.lastModified ?? "").slice(0, 19)
|
|
1133
|
+
])
|
|
1134
|
+
);
|
|
1135
|
+
});
|
|
1136
|
+
});
|
|
1137
|
+
projects.command("get <project_id>").description("Fetch a project (summary view; --raw for the editable blob)").option("--raw", "return the raw editable JSON blob").action(async function(projectId) {
|
|
1138
|
+
const ctx = buildContext(this);
|
|
1139
|
+
const result = await ctx.client.callTool("get_project", {
|
|
1140
|
+
project_id: projectId,
|
|
1141
|
+
view: this.opts().raw ? "raw" : "summary"
|
|
1142
|
+
});
|
|
1143
|
+
emit(ctx.out, result);
|
|
1144
|
+
});
|
|
1145
|
+
projects.command("delete <project_id>").description("Permanently delete a project (cannot be undone)").option("--yes", "skip the confirmation").action(async function(projectId) {
|
|
1146
|
+
const ctx = buildContext(this);
|
|
1147
|
+
if (!this.opts().yes) {
|
|
1148
|
+
throw new CliError(
|
|
1149
|
+
"Refusing to delete without --yes. Deletion is permanent for every collaborator.",
|
|
1150
|
+
EXIT.USAGE
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
const result = await ctx.client.callTool("delete_project", { project_id: projectId });
|
|
1154
|
+
emit(ctx.out, result, (o) => note(o, fmt.green(o, `Deleted project ${projectId}.`)));
|
|
1155
|
+
});
|
|
1156
|
+
projects.command("favorite <project_id>").description("Star or unstar a project").option("--remove", "unstar instead of star").action(async function(projectId) {
|
|
1157
|
+
const ctx = buildContext(this);
|
|
1158
|
+
const favorite = !this.opts().remove;
|
|
1159
|
+
const result = await ctx.client.callTool("set_project_favorite", {
|
|
1160
|
+
project_id: projectId,
|
|
1161
|
+
favorite
|
|
1162
|
+
});
|
|
1163
|
+
emit(ctx.out, result, (o) => note(o, favorite ? "Starred." : "Unstarred."));
|
|
1164
|
+
});
|
|
1165
|
+
projects.command("open <project_id>").description("Open the project in your browser").action(async function(projectId) {
|
|
1166
|
+
const ctx = buildContext(this);
|
|
1167
|
+
const result = await ctx.client.callTool("get_project", {
|
|
1168
|
+
project_id: projectId,
|
|
1169
|
+
view: "summary"
|
|
1170
|
+
});
|
|
1171
|
+
const url = result?.urls?.storyboard ?? result?.urls?.script ?? `${ctx.baseUrl}/projects/${projectId}`;
|
|
1172
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1173
|
+
try {
|
|
1174
|
+
const child = spawn2(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
|
|
1175
|
+
child.unref();
|
|
1176
|
+
} catch {
|
|
1177
|
+
}
|
|
1178
|
+
emit(ctx.out, { url }, (o) => note(o, url));
|
|
1179
|
+
});
|
|
1180
|
+
const checkpoint = program.command("checkpoint").description("Project version checkpoints");
|
|
1181
|
+
checkpoint.command("create <project_id>").description("Snapshot the project as a new version").option("--name <name>", "version name").option("--description <text>", "version description").action(async function(projectId) {
|
|
1182
|
+
const ctx = buildContext(this);
|
|
1183
|
+
const opts = this.opts();
|
|
1184
|
+
const result = await ctx.client.callTool(
|
|
1185
|
+
"create_project_checkpoint",
|
|
1186
|
+
compact({ project_id: projectId, name: opts.name, description: opts.description })
|
|
1187
|
+
);
|
|
1188
|
+
emit(ctx.out, result);
|
|
1189
|
+
});
|
|
1190
|
+
checkpoint.command("list <project_id>").description("List a project's checkpoints").action(async function(projectId) {
|
|
1191
|
+
const ctx = buildContext(this);
|
|
1192
|
+
const result = await ctx.client.callTool("list_project_checkpoints", { project_id: projectId });
|
|
1193
|
+
emit(ctx.out, result);
|
|
1194
|
+
});
|
|
1195
|
+
checkpoint.command("restore <project_id> [version_number]").description("Restore a project to a saved version (current state is snapshotted first)").option("--checkpoint-id <id>", "restore by checkpoint UUID instead of version number").action(async function(projectId, versionNumber) {
|
|
1196
|
+
const ctx = buildContext(this);
|
|
1197
|
+
const opts = this.opts();
|
|
1198
|
+
if (!versionNumber && !opts.checkpointId) {
|
|
1199
|
+
throw new CliError("Pass a version_number or --checkpoint-id.", EXIT.USAGE);
|
|
1200
|
+
}
|
|
1201
|
+
const result = await ctx.client.callTool(
|
|
1202
|
+
"restore_project_checkpoint",
|
|
1203
|
+
compact({
|
|
1204
|
+
project_id: projectId,
|
|
1205
|
+
version_number: versionNumber ? Number(versionNumber) : void 0,
|
|
1206
|
+
checkpoint_id: opts.checkpointId
|
|
1207
|
+
})
|
|
1208
|
+
);
|
|
1209
|
+
emit(ctx.out, result);
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// src/commands/generate.ts
|
|
1214
|
+
import fs5 from "fs";
|
|
1215
|
+
|
|
1216
|
+
// src/core/poll.ts
|
|
1217
|
+
function nextPollDelay(baseMs, elapsedMs, adaptive = true) {
|
|
1218
|
+
if (!adaptive) return baseMs;
|
|
1219
|
+
const factor = elapsedMs >= 18e4 ? 3 : elapsedMs >= 6e4 ? 2 : 1;
|
|
1220
|
+
const scaled = Math.min(baseMs * factor, 15e3);
|
|
1221
|
+
const jitter = scaled * 0.1 * (Math.random() * 2 - 1);
|
|
1222
|
+
return Math.max(250, Math.round(scaled + jitter));
|
|
1223
|
+
}
|
|
1224
|
+
var GENERATION_TERMINAL = /* @__PURE__ */ new Set(["completed", "failed"]);
|
|
1225
|
+
var EXPORT_TERMINAL = /* @__PURE__ */ new Set(["finished", "failed"]);
|
|
1226
|
+
function sleep(ms) {
|
|
1227
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1228
|
+
}
|
|
1229
|
+
function extractOutputUrls(payload) {
|
|
1230
|
+
const urls = [];
|
|
1231
|
+
const push = (value) => {
|
|
1232
|
+
if (typeof value === "string" && /^https?:\/\//.test(value)) urls.push(value);
|
|
1233
|
+
};
|
|
1234
|
+
if (Array.isArray(payload?.outputUrls)) payload.outputUrls.forEach(push);
|
|
1235
|
+
if (Array.isArray(payload?.output_urls)) payload.output_urls.forEach(push);
|
|
1236
|
+
push(payload?.outputUrl);
|
|
1237
|
+
push(payload?.output_url);
|
|
1238
|
+
push(payload?.video_url);
|
|
1239
|
+
push(payload?.videoUrl);
|
|
1240
|
+
push(payload?.image_url);
|
|
1241
|
+
push(payload?.imageUrl);
|
|
1242
|
+
push(payload?.url);
|
|
1243
|
+
push(payload?.audio_url);
|
|
1244
|
+
push(payload?.audioUrl);
|
|
1245
|
+
push(payload?.speech_url);
|
|
1246
|
+
push(payload?.public_url);
|
|
1247
|
+
return [...new Set(urls)];
|
|
1248
|
+
}
|
|
1249
|
+
async function pollGeneration(client, jobId, options = {}) {
|
|
1250
|
+
const intervalMs = options.intervalMs ?? 3e3;
|
|
1251
|
+
const timeoutMs = options.timeoutMs ?? 6e5;
|
|
1252
|
+
const startedAt = Date.now();
|
|
1253
|
+
const deadline = startedAt + timeoutMs;
|
|
1254
|
+
for (; ; ) {
|
|
1255
|
+
const payload = await client.callTool("check_generation_status", { job_id: jobId });
|
|
1256
|
+
const status = String(payload?.status ?? "unknown");
|
|
1257
|
+
options.onTick?.(status, payload);
|
|
1258
|
+
if (GENERATION_TERMINAL.has(status)) {
|
|
1259
|
+
return { status, outputUrls: extractOutputUrls(payload), payload };
|
|
1260
|
+
}
|
|
1261
|
+
if (Date.now() > deadline) {
|
|
1262
|
+
throw new TimeoutError(
|
|
1263
|
+
`Timed out after ${Math.round(timeoutMs / 1e3)}s waiting for job ${jobId} (last status: ${status}). It is still running server-side \u2014 check later with: videodraft status ${jobId}`
|
|
1264
|
+
);
|
|
1265
|
+
}
|
|
1266
|
+
await sleep(nextPollDelay(intervalMs, Date.now() - startedAt, options.adaptive));
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
async function pollGenerationsBatch(client, jobIds, options = {}) {
|
|
1270
|
+
const intervalMs = options.intervalMs ?? 3e3;
|
|
1271
|
+
const timeoutMs = options.timeoutMs ?? 6e5;
|
|
1272
|
+
const startedAt = Date.now();
|
|
1273
|
+
const deadline = startedAt + timeoutMs;
|
|
1274
|
+
const done = /* @__PURE__ */ new Map();
|
|
1275
|
+
let pending2 = [...new Set(jobIds)];
|
|
1276
|
+
const MAX_BATCH = 25;
|
|
1277
|
+
while (pending2.length > 0) {
|
|
1278
|
+
const replies = [];
|
|
1279
|
+
for (let at = 0; at < pending2.length; at += MAX_BATCH) {
|
|
1280
|
+
const chunk = pending2.slice(at, at + MAX_BATCH);
|
|
1281
|
+
replies.push(
|
|
1282
|
+
...await client.callToolBatch(
|
|
1283
|
+
chunk.map((id) => ({ name: "check_generation_status", args: { job_id: id } }))
|
|
1284
|
+
)
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
const still = [];
|
|
1288
|
+
for (let i = 0; i < pending2.length; i++) {
|
|
1289
|
+
const jobId = pending2[i];
|
|
1290
|
+
const reply = replies[i];
|
|
1291
|
+
if (!reply.ok) {
|
|
1292
|
+
const result = {
|
|
1293
|
+
status: "failed",
|
|
1294
|
+
outputUrls: [],
|
|
1295
|
+
payload: { error: reply.error }
|
|
1296
|
+
};
|
|
1297
|
+
done.set(jobId, result);
|
|
1298
|
+
options.onJobDone?.(jobId, result);
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
const payload = reply.result;
|
|
1302
|
+
const status = String(payload?.status ?? "unknown");
|
|
1303
|
+
if (GENERATION_TERMINAL.has(status)) {
|
|
1304
|
+
const result = { status, outputUrls: extractOutputUrls(payload), payload };
|
|
1305
|
+
done.set(jobId, result);
|
|
1306
|
+
options.onJobDone?.(jobId, result);
|
|
1307
|
+
} else {
|
|
1308
|
+
still.push(jobId);
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
pending2 = still;
|
|
1312
|
+
options.onTick?.(`${done.size}/${jobIds.length} done`, { pending: pending2.length });
|
|
1313
|
+
if (pending2.length === 0) break;
|
|
1314
|
+
if (Date.now() > deadline) {
|
|
1315
|
+
throw new TimeoutError(
|
|
1316
|
+
`Timed out after ${Math.round(timeoutMs / 1e3)}s with ${pending2.length} job(s) still running: ${pending2.join(", ")}. They continue server-side \u2014 resume with: videodraft wait ${pending2.join(" ")}`
|
|
1317
|
+
);
|
|
1318
|
+
}
|
|
1319
|
+
await sleep(nextPollDelay(intervalMs, Date.now() - startedAt, options.adaptive));
|
|
1320
|
+
}
|
|
1321
|
+
return done;
|
|
1322
|
+
}
|
|
1323
|
+
async function pollExport(client, ref, options = {}) {
|
|
1324
|
+
const intervalMs = options.intervalMs ?? 5e3;
|
|
1325
|
+
const timeoutMs = options.timeoutMs ?? 12e5;
|
|
1326
|
+
const startedAt = Date.now();
|
|
1327
|
+
const deadline = startedAt + timeoutMs;
|
|
1328
|
+
for (; ; ) {
|
|
1329
|
+
const payload = await client.callTool("check_export_status", {
|
|
1330
|
+
...ref.exportId ? { export_id: ref.exportId } : {},
|
|
1331
|
+
...ref.projectId ? { project_id: ref.projectId } : {}
|
|
1332
|
+
});
|
|
1333
|
+
const status = String(payload?.status ?? "unknown");
|
|
1334
|
+
options.onTick?.(status, payload);
|
|
1335
|
+
if (EXPORT_TERMINAL.has(status)) {
|
|
1336
|
+
return { status, videoUrl: payload?.video_url ?? payload?.videoUrl, payload };
|
|
1337
|
+
}
|
|
1338
|
+
if (Date.now() > deadline) {
|
|
1339
|
+
throw new TimeoutError(
|
|
1340
|
+
`Timed out waiting for export${ref.exportId ? ` ${ref.exportId}` : ""} (last status: ${status}).`
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
await sleep(nextPollDelay(intervalMs, Date.now() - startedAt, options.adaptive));
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// src/core/download.ts
|
|
1348
|
+
import fs3 from "fs";
|
|
1349
|
+
import path2 from "path";
|
|
1350
|
+
import { pipeline } from "stream/promises";
|
|
1351
|
+
import { Readable } from "stream";
|
|
1352
|
+
function extFromUrl(url, fallback = "bin") {
|
|
1353
|
+
try {
|
|
1354
|
+
const pathname = new URL(url).pathname;
|
|
1355
|
+
const ext = path2.extname(pathname).replace(/^\./, "");
|
|
1356
|
+
return ext || fallback;
|
|
1357
|
+
} catch {
|
|
1358
|
+
return fallback;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
function renderTemplate(template, vars) {
|
|
1362
|
+
const hasPlaceholders = /\{(job_id|index|ext|name)\}/.test(template);
|
|
1363
|
+
if (!hasPlaceholders) {
|
|
1364
|
+
const looksLikeFile = path2.extname(template) !== "";
|
|
1365
|
+
if (looksLikeFile) {
|
|
1366
|
+
if (vars.index > 0) {
|
|
1367
|
+
const ext = path2.extname(template);
|
|
1368
|
+
return `${template.slice(0, -ext.length)}_${vars.index}${ext}`;
|
|
1369
|
+
}
|
|
1370
|
+
return template;
|
|
1371
|
+
}
|
|
1372
|
+
return path2.join(template, `${vars.job_id ?? vars.name ?? "output"}_${vars.index}.${vars.ext}`);
|
|
1373
|
+
}
|
|
1374
|
+
return template.replaceAll("{job_id}", vars.job_id ?? "job").replaceAll("{index}", String(vars.index)).replaceAll("{ext}", vars.ext).replaceAll("{name}", vars.name ?? "output");
|
|
1375
|
+
}
|
|
1376
|
+
async function downloadUrl(url, destPath, fetchImpl = fetch) {
|
|
1377
|
+
const res = await fetchImpl(url, { signal: AbortSignal.timeout(6e5) });
|
|
1378
|
+
if (!res.ok || !res.body) {
|
|
1379
|
+
throw new CliError(`Download failed (HTTP ${res.status}): ${url}`);
|
|
1380
|
+
}
|
|
1381
|
+
fs3.mkdirSync(path2.dirname(path2.resolve(destPath)), { recursive: true });
|
|
1382
|
+
await pipeline(Readable.fromWeb(res.body), fs3.createWriteStream(destPath));
|
|
1383
|
+
const bytes = fs3.statSync(destPath).size;
|
|
1384
|
+
return { url, path: destPath, bytes };
|
|
1385
|
+
}
|
|
1386
|
+
async function downloadOutputs(urls, template, vars, fetchImpl = fetch) {
|
|
1387
|
+
const files = [];
|
|
1388
|
+
for (let i = 0; i < urls.length; i++) {
|
|
1389
|
+
const url = urls[i];
|
|
1390
|
+
const dest = renderTemplate(template, {
|
|
1391
|
+
job_id: vars.job_id,
|
|
1392
|
+
name: vars.name,
|
|
1393
|
+
index: i,
|
|
1394
|
+
ext: extFromUrl(url)
|
|
1395
|
+
});
|
|
1396
|
+
files.push(await downloadUrl(url, dest, fetchImpl));
|
|
1397
|
+
}
|
|
1398
|
+
return files;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// src/core/upload.ts
|
|
1402
|
+
import fs4 from "fs";
|
|
1403
|
+
import path3 from "path";
|
|
1404
|
+
var MIME_BY_EXT = {
|
|
1405
|
+
png: "image/png",
|
|
1406
|
+
jpg: "image/jpeg",
|
|
1407
|
+
jpeg: "image/jpeg",
|
|
1408
|
+
webp: "image/webp",
|
|
1409
|
+
gif: "image/gif",
|
|
1410
|
+
heic: "image/heic",
|
|
1411
|
+
mp4: "video/mp4",
|
|
1412
|
+
mov: "video/quicktime",
|
|
1413
|
+
webm: "video/webm",
|
|
1414
|
+
m4v: "video/x-m4v",
|
|
1415
|
+
mp3: "audio/mpeg",
|
|
1416
|
+
wav: "audio/wav",
|
|
1417
|
+
m4a: "audio/mp4",
|
|
1418
|
+
aac: "audio/aac",
|
|
1419
|
+
ogg: "audio/ogg",
|
|
1420
|
+
flac: "audio/flac"
|
|
1421
|
+
};
|
|
1422
|
+
function guessContentType(filename) {
|
|
1423
|
+
const ext = path3.extname(filename).replace(/^\./, "").toLowerCase();
|
|
1424
|
+
return MIME_BY_EXT[ext];
|
|
1425
|
+
}
|
|
1426
|
+
async function uploadFile(client, localPath, options = {}) {
|
|
1427
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1428
|
+
const resolved = path3.resolve(localPath);
|
|
1429
|
+
if (!fs4.existsSync(resolved)) {
|
|
1430
|
+
throw new CliError(`File not found: ${resolved}`);
|
|
1431
|
+
}
|
|
1432
|
+
const filename = path3.basename(resolved);
|
|
1433
|
+
const contentType = options.contentType ?? guessContentType(filename);
|
|
1434
|
+
if (!contentType) {
|
|
1435
|
+
throw new CliError(
|
|
1436
|
+
`Cannot infer media type for "${filename}". Pass --content-type (image/*, video/* or audio/*).`
|
|
1437
|
+
);
|
|
1438
|
+
}
|
|
1439
|
+
const created = await client.callTool("create_media_upload", {
|
|
1440
|
+
filename,
|
|
1441
|
+
content_type: contentType
|
|
1442
|
+
});
|
|
1443
|
+
const uploadUrl = created?.upload_url;
|
|
1444
|
+
const filePath = created?.file_path;
|
|
1445
|
+
if (!uploadUrl || !filePath) {
|
|
1446
|
+
throw new CliError("create_media_upload did not return upload_url/file_path.");
|
|
1447
|
+
}
|
|
1448
|
+
const body = fs4.readFileSync(resolved);
|
|
1449
|
+
const putRes = await fetchImpl(uploadUrl, {
|
|
1450
|
+
method: "PUT",
|
|
1451
|
+
headers: { "content-type": contentType },
|
|
1452
|
+
body,
|
|
1453
|
+
signal: AbortSignal.timeout(6e5)
|
|
1454
|
+
});
|
|
1455
|
+
if (!putRes.ok) {
|
|
1456
|
+
throw new CliError(`Upload PUT failed (HTTP ${putRes.status}). The presigned URL may have expired \u2014 retry.`);
|
|
1457
|
+
}
|
|
1458
|
+
const finalized = await client.callTool("finalize_media_upload", {
|
|
1459
|
+
file_path: filePath,
|
|
1460
|
+
original_filename: filename
|
|
1461
|
+
});
|
|
1462
|
+
const url = finalized?.url ?? finalized?.cdn_url;
|
|
1463
|
+
if (!url) {
|
|
1464
|
+
throw new CliError("finalize_media_upload did not return a public url.");
|
|
1465
|
+
}
|
|
1466
|
+
return { ...finalized, url, file_path: filePath };
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/commands/generate.ts
|
|
1470
|
+
var URI_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
1471
|
+
async function resolveRefs(ctx, refs) {
|
|
1472
|
+
const resolved = [];
|
|
1473
|
+
for (const ref of refs) {
|
|
1474
|
+
if (URI_SCHEME.test(ref)) {
|
|
1475
|
+
resolved.push(ref);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
if (!fs5.existsSync(ref)) {
|
|
1479
|
+
throw new Error(`"${ref}" is neither a URL nor an existing local file.`);
|
|
1480
|
+
}
|
|
1481
|
+
note(ctx.out, fmt.dim(ctx.out, `Uploading ${ref}\u2026`));
|
|
1482
|
+
const uploaded = await uploadFile(ctx.client, ref);
|
|
1483
|
+
resolved.push(uploaded.url);
|
|
1484
|
+
}
|
|
1485
|
+
return resolved;
|
|
1486
|
+
}
|
|
1487
|
+
function parseSegments(values) {
|
|
1488
|
+
return values.map((v) => {
|
|
1489
|
+
const i = v.lastIndexOf(":");
|
|
1490
|
+
if (i <= 0 || i === v.length - 1) {
|
|
1491
|
+
throw new CliError(`--segment expects "prompt:seconds", got: ${v}`, EXIT.USAGE);
|
|
1492
|
+
}
|
|
1493
|
+
const prompt = v.slice(0, i).trim();
|
|
1494
|
+
const duration = Number(v.slice(i + 1));
|
|
1495
|
+
if (!prompt || !Number.isFinite(duration) || duration <= 0) {
|
|
1496
|
+
throw new CliError(`--segment "${v}" must be "<prompt>:<positive seconds>".`, EXIT.USAGE);
|
|
1497
|
+
}
|
|
1498
|
+
return { prompt, duration };
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1501
|
+
async function printEstimate(ctx, params) {
|
|
1502
|
+
const estimate = await ctx.client.callTool(
|
|
1503
|
+
"get_model_costs",
|
|
1504
|
+
compact({
|
|
1505
|
+
model_id: params.model,
|
|
1506
|
+
type: params.type,
|
|
1507
|
+
duration_seconds: params.duration,
|
|
1508
|
+
resolution: params.resolution,
|
|
1509
|
+
quality: params.quality,
|
|
1510
|
+
rendering_speed: params.renderingSpeed,
|
|
1511
|
+
generate_audio: params.audio,
|
|
1512
|
+
num_images: params.num
|
|
1513
|
+
})
|
|
1514
|
+
);
|
|
1515
|
+
emit(ctx.out, { estimate, note: "No credits were spent (--estimate)." });
|
|
1516
|
+
}
|
|
1517
|
+
async function handleAsyncJob(ctx, submitted, options) {
|
|
1518
|
+
const jobId = submitted?.job_id ?? submitted?.jobId;
|
|
1519
|
+
if (!jobId || !options.wait) {
|
|
1520
|
+
emit(ctx.out, submitted, (o) => {
|
|
1521
|
+
note(o, `Submitted${jobId ? ` \u2014 job ${jobId}` : ""}.`);
|
|
1522
|
+
if (jobId) note(o, fmt.dim(o, `Poll with: videodraft status ${jobId}`));
|
|
1523
|
+
});
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
const spin = spinner(ctx.out, `${options.label} (job ${jobId})\u2026`);
|
|
1527
|
+
try {
|
|
1528
|
+
const result = await pollGeneration(ctx.client, jobId, {
|
|
1529
|
+
intervalMs: ctx.intervalMs,
|
|
1530
|
+
timeoutMs: ctx.timeoutMs,
|
|
1531
|
+
adaptive: ctx.adaptive,
|
|
1532
|
+
onTick: (status) => spin.update(`${options.label} \u2014 ${status} (job ${jobId})`)
|
|
1533
|
+
});
|
|
1534
|
+
spin.stop();
|
|
1535
|
+
if (result.status === "failed") {
|
|
1536
|
+
emit(ctx.out, { job_id: jobId, ...result.payload }, (o) => {
|
|
1537
|
+
note(o, fmt.red(o, `Job ${jobId} failed: ${result.payload?.error ?? "unknown error"}`));
|
|
1538
|
+
});
|
|
1539
|
+
process.exitCode = 1;
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
let downloaded;
|
|
1543
|
+
if (options.download && result.outputUrls.length > 0) {
|
|
1544
|
+
downloaded = await downloadOutputs(result.outputUrls, options.download, { job_id: jobId });
|
|
1545
|
+
}
|
|
1546
|
+
emit(
|
|
1547
|
+
ctx.out,
|
|
1548
|
+
{ job_id: jobId, status: result.status, outputs: result.outputUrls, downloaded_files: downloaded },
|
|
1549
|
+
(o) => {
|
|
1550
|
+
note(o, fmt.green(o, `Completed \u2014 job ${jobId}`));
|
|
1551
|
+
for (const url of result.outputUrls) process.stdout.write(`${url}
|
|
1552
|
+
`);
|
|
1553
|
+
for (const file of downloaded ?? []) note(o, fmt.dim(o, `saved ${file.path}`));
|
|
1554
|
+
}
|
|
1555
|
+
);
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
spin.stop();
|
|
1558
|
+
throw err;
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
function registerGenerateCommands(program) {
|
|
1562
|
+
const generate = program.command("generate").description("Generate images, video, voiceovers and music");
|
|
1563
|
+
generate.command("image <prompt...>").description("Generate an image (async; waits by default)").option("--model <id>", "image model id (default nano-banana-2)").option("--ar <ratio>", 'aspect ratio, e.g. "16:9"').option("--resolution <res>", 'e.g. "1K", "2K", "4K"').option("--quality <tier>", "model-specific quality tier").option("--rendering-speed <tier>", 'Ideogram speed/cost tier, e.g. V4 "Turbo"/"Balanced"/"Quality"').option("--num <n>", "variations of this prompt in one call (1-4)").option("--seed <n>", "seed (supported models only, e.g. Flux, Ideogram V4)").option("--ref <url|file>", "reference image (repeatable; local files are uploaded)", collect, []).option("--video-ref <url|file>", "video reference \u2014 nano-banana-2 only (http(s)/gs:///YouTube, or local file)").option("--style <id>", "style preset id").option("--project <id>", "attach to a project").option("--session <id>", "AI Studio session id").option("--scene <n>", "0-based scene index (with --project: writes onto that shot)").option("--shot <n>", "0-based shot index").option("--download <path>", "download outputs (template: {job_id} {index} {ext})").option("--no-wait", "submit and return the job id immediately").option("--estimate", "print the cost estimate and exit (spends nothing)").action(async function(promptWords) {
|
|
1564
|
+
const ctx = buildContext(this);
|
|
1565
|
+
const opts = this.opts();
|
|
1566
|
+
const prompt = promptWords.join(" ");
|
|
1567
|
+
if (opts.estimate) {
|
|
1568
|
+
await printEstimate(ctx, {
|
|
1569
|
+
model: opts.model,
|
|
1570
|
+
type: "image",
|
|
1571
|
+
resolution: opts.resolution,
|
|
1572
|
+
quality: opts.quality,
|
|
1573
|
+
renderingSpeed: opts.renderingSpeed,
|
|
1574
|
+
num: opts.num ? Number(opts.num) : void 0
|
|
1575
|
+
});
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
const [refs, videoRef] = await Promise.all([
|
|
1579
|
+
resolveRefs(ctx, opts.ref ?? []),
|
|
1580
|
+
opts.videoRef ? resolveRefs(ctx, [opts.videoRef]).then((r) => r[0]) : void 0
|
|
1581
|
+
]);
|
|
1582
|
+
capture("cli_generate", { kind: "image", model: opts.model ?? "default", wait: opts.wait !== false });
|
|
1583
|
+
const submitted = await ctx.client.callTool(
|
|
1584
|
+
"generate_image",
|
|
1585
|
+
compact({
|
|
1586
|
+
prompt,
|
|
1587
|
+
model: opts.model,
|
|
1588
|
+
aspect_ratio: opts.ar,
|
|
1589
|
+
resolution: opts.resolution,
|
|
1590
|
+
quality: opts.quality,
|
|
1591
|
+
rendering_speed: opts.renderingSpeed,
|
|
1592
|
+
num_images: opts.num ? Number(opts.num) : void 0,
|
|
1593
|
+
seed: opts.seed ? Number(opts.seed) : void 0,
|
|
1594
|
+
reference_images: refs.length > 0 ? refs : void 0,
|
|
1595
|
+
video_url: videoRef,
|
|
1596
|
+
style: opts.style,
|
|
1597
|
+
project_id: opts.project,
|
|
1598
|
+
session_id: opts.session,
|
|
1599
|
+
scene_index: opts.scene !== void 0 ? Number(opts.scene) : void 0,
|
|
1600
|
+
shot_index: opts.shot !== void 0 ? Number(opts.shot) : void 0
|
|
1601
|
+
})
|
|
1602
|
+
);
|
|
1603
|
+
await handleAsyncJob(ctx, submitted, {
|
|
1604
|
+
wait: opts.wait !== false,
|
|
1605
|
+
download: opts.download,
|
|
1606
|
+
label: "Generating image"
|
|
1607
|
+
});
|
|
1608
|
+
});
|
|
1609
|
+
generate.command("video [prompt...]").description("Generate a video clip (async; per-second pricing \u2014 see --estimate)").option("--model <id>", "video model id (default google-veo3.1 fast)").option("--ar <ratio>", 'aspect ratio, e.g. "16:9", "9:16"').option("--duration <seconds>", "clip duration in seconds").option("--resolution <res>", 'e.g. "720p", "1080p"').option("--quality <tier>", 'e.g. "fast", "quality", "standard", "pro"').option("--audio", "generate native model audio").option("--no-audio", "disable native model audio").option("--start-image <url|file>", "start frame (image-to-video)").option("--end-image <url|file>", "end frame (supported models only)").option("--ref <url|file>", "reference image (repeatable)", collect, []).option("--ref-video <url|file>", "reference video (repeatable; Seedance 2, Wan 2.7; local files uploaded)", collect, []).option("--ref-audio <url|file>", "reference audio (repeatable; Seedance 2; local files uploaded)", collect, []).option("--segment <prompt:seconds>", "multi-prompt segment (repeatable; Kling 3.0 / 3.0 Turbo / O3)", collect, []).option("--negative <text>", "negative prompt (Kling/Wan/Luma)").option("--seed <n>", "seed").option("--project <id>", "attach to a project").option("--session <id>", "AI Studio session id").option("--scene <n>", "0-based scene index").option("--shot <n>", "0-based shot index").option("--download <path>", "download outputs (template: {job_id} {index} {ext})").option("--no-wait", "submit and return the job id immediately").option("--estimate", "print the cost estimate and exit (spends nothing)").action(async function(promptWords = []) {
|
|
1610
|
+
const ctx = buildContext(this);
|
|
1611
|
+
const opts = this.opts();
|
|
1612
|
+
const prompt = promptWords.join(" ").trim();
|
|
1613
|
+
const duration = opts.duration ? Number(opts.duration) : void 0;
|
|
1614
|
+
if (opts.estimate) {
|
|
1615
|
+
await printEstimate(ctx, {
|
|
1616
|
+
model: opts.model,
|
|
1617
|
+
type: "video",
|
|
1618
|
+
duration,
|
|
1619
|
+
resolution: opts.resolution,
|
|
1620
|
+
quality: opts.quality,
|
|
1621
|
+
audio: opts.audio
|
|
1622
|
+
});
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
const [refs, refVideos, refAudios, startImage, endImage] = await Promise.all([
|
|
1626
|
+
resolveRefs(ctx, opts.ref ?? []),
|
|
1627
|
+
resolveRefs(ctx, opts.refVideo ?? []),
|
|
1628
|
+
resolveRefs(ctx, opts.refAudio ?? []),
|
|
1629
|
+
opts.startImage ? resolveRefs(ctx, [opts.startImage]).then((r) => r[0]) : void 0,
|
|
1630
|
+
opts.endImage ? resolveRefs(ctx, [opts.endImage]).then((r) => r[0]) : void 0
|
|
1631
|
+
]);
|
|
1632
|
+
const segments = parseSegments(opts.segment ?? []);
|
|
1633
|
+
if (!prompt && segments.length === 0 && !startImage && refVideos.length === 0) {
|
|
1634
|
+
throw new CliError(
|
|
1635
|
+
"Provide a prompt, --segment (multi-prompt), or --start-image.",
|
|
1636
|
+
EXIT.USAGE
|
|
1637
|
+
);
|
|
1638
|
+
}
|
|
1639
|
+
capture("cli_generate", { kind: "video", model: opts.model ?? "default", wait: opts.wait !== false });
|
|
1640
|
+
const submitted = await ctx.client.callTool(
|
|
1641
|
+
"generate_video",
|
|
1642
|
+
compact({
|
|
1643
|
+
prompt: prompt || void 0,
|
|
1644
|
+
model: opts.model,
|
|
1645
|
+
aspect_ratio: opts.ar,
|
|
1646
|
+
duration_seconds: duration,
|
|
1647
|
+
resolution: opts.resolution,
|
|
1648
|
+
quality: opts.quality,
|
|
1649
|
+
generate_audio: opts.audio,
|
|
1650
|
+
start_image_url: startImage,
|
|
1651
|
+
end_image_url: endImage,
|
|
1652
|
+
reference_images: refs.length > 0 ? refs : void 0,
|
|
1653
|
+
reference_videos: refVideos.length > 0 ? refVideos : void 0,
|
|
1654
|
+
reference_audio: refAudios.length > 0 ? refAudios : void 0,
|
|
1655
|
+
multi_prompt: segments.length > 0 ? segments : void 0,
|
|
1656
|
+
negative_prompt: opts.negative,
|
|
1657
|
+
seed: opts.seed ? Number(opts.seed) : void 0,
|
|
1658
|
+
project_id: opts.project,
|
|
1659
|
+
session_id: opts.session,
|
|
1660
|
+
scene_index: opts.scene !== void 0 ? Number(opts.scene) : void 0,
|
|
1661
|
+
shot_index: opts.shot !== void 0 ? Number(opts.shot) : void 0
|
|
1662
|
+
})
|
|
1663
|
+
);
|
|
1664
|
+
await handleAsyncJob(ctx, submitted, {
|
|
1665
|
+
wait: opts.wait !== false,
|
|
1666
|
+
download: opts.download,
|
|
1667
|
+
label: "Generating video"
|
|
1668
|
+
});
|
|
1669
|
+
});
|
|
1670
|
+
generate.command("voiceover <text...>").description("Generate TTS audio (synchronous \u2014 returns an audio URL)").option("--voice <id>", "voice id (see `videodraft models voices`)").option("--language <bcp47>", 'target language, default "en"').option("--project <id>", "attach to a project").option("--scene <n>", "0-based scene index \u2014 wires the audio onto that scene").option("--session <id>", "AI Studio session id").option("--download <path>", "download the audio file").action(async function(textWords) {
|
|
1671
|
+
const ctx = buildContext(this);
|
|
1672
|
+
const opts = this.opts();
|
|
1673
|
+
capture("cli_generate", { kind: "voiceover" });
|
|
1674
|
+
const result = await ctx.client.callTool(
|
|
1675
|
+
"generate_voiceover",
|
|
1676
|
+
compact({
|
|
1677
|
+
text: textWords.join(" "),
|
|
1678
|
+
voice_id: opts.voice,
|
|
1679
|
+
target_language: opts.language,
|
|
1680
|
+
project_id: opts.project,
|
|
1681
|
+
session_id: opts.session,
|
|
1682
|
+
scene_index: opts.scene !== void 0 ? Number(opts.scene) : void 0
|
|
1683
|
+
})
|
|
1684
|
+
);
|
|
1685
|
+
const urls = extractOutputUrls(result);
|
|
1686
|
+
let downloaded;
|
|
1687
|
+
if (opts.download && urls.length > 0) {
|
|
1688
|
+
downloaded = await downloadOutputs(urls, opts.download, { name: "voiceover" });
|
|
1689
|
+
}
|
|
1690
|
+
emit(ctx.out, { ...result, downloaded_files: downloaded }, (o) => {
|
|
1691
|
+
for (const url of urls) process.stdout.write(`${url}
|
|
1692
|
+
`);
|
|
1693
|
+
for (const f of downloaded ?? []) note(o, fmt.dim(o, `saved ${f.path}`));
|
|
1694
|
+
});
|
|
1695
|
+
});
|
|
1696
|
+
generate.command("music <prompt...>").description("Generate background music (Lyria 3)").option("--model <id>", "lyria-3-clip-preview (30s, default) | lyria-3-pro-preview (180s)").option("--ref <url|file>", "reference image to inspire the music (repeatable)", collect, []).option("--project <id>", "link the generation to a project's AI Studio session").option("--attach <project_id>", "also set the track as that project's background music").option("--volume <n>", "0-100 BGM volume when attaching (default 30)").option("--bgm-disabled", "when attaching, store the BGM as disabled (enabled:false)").option("--session <id>", "AI Studio session id").option("--download <path>", "download the audio file").action(async function(promptWords) {
|
|
1697
|
+
const ctx = buildContext(this);
|
|
1698
|
+
const opts = this.opts();
|
|
1699
|
+
const refs = await resolveRefs(ctx, opts.ref ?? []);
|
|
1700
|
+
capture("cli_generate", { kind: "music", model: opts.model ?? "default" });
|
|
1701
|
+
const result = await ctx.client.callTool(
|
|
1702
|
+
"generate_music",
|
|
1703
|
+
compact({
|
|
1704
|
+
prompt: promptWords.join(" "),
|
|
1705
|
+
model: opts.model,
|
|
1706
|
+
image_urls: refs.length > 0 ? refs : void 0,
|
|
1707
|
+
project_id: opts.project,
|
|
1708
|
+
attach_to_project_id: opts.attach,
|
|
1709
|
+
volume: opts.volume ? Number(opts.volume) : void 0,
|
|
1710
|
+
enabled: opts.bgmDisabled ? false : void 0,
|
|
1711
|
+
session_id: opts.session
|
|
1712
|
+
})
|
|
1713
|
+
);
|
|
1714
|
+
const urls = extractOutputUrls(result);
|
|
1715
|
+
let downloaded;
|
|
1716
|
+
if (opts.download && urls.length > 0) {
|
|
1717
|
+
downloaded = await downloadOutputs(urls, opts.download, { name: "music" });
|
|
1718
|
+
}
|
|
1719
|
+
emit(ctx.out, { ...result, downloaded_files: downloaded }, (o) => {
|
|
1720
|
+
for (const url of urls) process.stdout.write(`${url}
|
|
1721
|
+
`);
|
|
1722
|
+
for (const f of downloaded ?? []) note(o, fmt.dim(o, `saved ${f.path}`));
|
|
1723
|
+
});
|
|
1724
|
+
});
|
|
1725
|
+
const upscale = program.command("upscale").description("Upscale images and videos (Topaz)");
|
|
1726
|
+
upscale.command("image <url|file>").description("Upscale an image (synchronous)").option("--scale <factor>", '"1x" | "2x" | "4x" (default 2x)').option("--session <id>", "AI Studio session id").option("--download <path>", "download the result").action(async function(source) {
|
|
1727
|
+
const ctx = buildContext(this);
|
|
1728
|
+
const opts = this.opts();
|
|
1729
|
+
const [url] = await resolveRefs(ctx, [source]);
|
|
1730
|
+
capture("cli_upscale", { kind: "image" });
|
|
1731
|
+
const result = await ctx.client.callTool(
|
|
1732
|
+
"upscale_image",
|
|
1733
|
+
compact({ image_url: url, scale: opts.scale, session_id: opts.session })
|
|
1734
|
+
);
|
|
1735
|
+
const urls = extractOutputUrls(result);
|
|
1736
|
+
let downloaded;
|
|
1737
|
+
if (opts.download && urls.length > 0) {
|
|
1738
|
+
downloaded = await downloadOutputs(urls, opts.download, { name: "upscaled" });
|
|
1739
|
+
}
|
|
1740
|
+
emit(ctx.out, { ...result, downloaded_files: downloaded }, (o) => {
|
|
1741
|
+
for (const u of urls) process.stdout.write(`${u}
|
|
1742
|
+
`);
|
|
1743
|
+
for (const f of downloaded ?? []) note(o, fmt.dim(o, `saved ${f.path}`));
|
|
1744
|
+
});
|
|
1745
|
+
});
|
|
1746
|
+
upscale.command("video <url|file>").description("Upscale a video (async; waits by default)").option("--scale <factor>", 'e.g. "2x" (default)').option("--session <id>", "AI Studio session id").option("--duration <seconds>", "source duration override (only if auto-probe fails, e.g. >100MB)").option("--width <px>", "source width override (only if auto-probe fails)").option("--height <px>", "source height override (only if auto-probe fails)").option("--download <path>", "download the result").option("--no-wait", "submit and return the job id immediately").action(async function(source) {
|
|
1747
|
+
const ctx = buildContext(this);
|
|
1748
|
+
const opts = this.opts();
|
|
1749
|
+
const [url] = await resolveRefs(ctx, [source]);
|
|
1750
|
+
capture("cli_upscale", { kind: "video" });
|
|
1751
|
+
const submitted = await ctx.client.callTool(
|
|
1752
|
+
"upscale_video",
|
|
1753
|
+
compact({
|
|
1754
|
+
video_url: url,
|
|
1755
|
+
scale: opts.scale,
|
|
1756
|
+
session_id: opts.session,
|
|
1757
|
+
duration_seconds: opts.duration ? Number(opts.duration) : void 0,
|
|
1758
|
+
video_width: opts.width ? Number(opts.width) : void 0,
|
|
1759
|
+
video_height: opts.height ? Number(opts.height) : void 0
|
|
1760
|
+
})
|
|
1761
|
+
);
|
|
1762
|
+
await handleAsyncJob(ctx, submitted, {
|
|
1763
|
+
wait: opts.wait !== false,
|
|
1764
|
+
download: opts.download,
|
|
1765
|
+
label: "Upscaling video"
|
|
1766
|
+
});
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// src/commands/pipeline.ts
|
|
1771
|
+
async function resolveMedia(ctx, src) {
|
|
1772
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(src)) return src;
|
|
1773
|
+
const uploaded = await uploadFile(ctx.client, src);
|
|
1774
|
+
return uploaded.url;
|
|
1775
|
+
}
|
|
1776
|
+
function registerPipelineCommands(program) {
|
|
1777
|
+
program.command("create <idea...>").description("Create a storyboard project from an idea (script \u2192 assets \u2192 storyboard)").option("--title <title>", "project title").option("--duration <length>", "short | medium | long | auto").option("--style <style>", 'style preset id, or "custom:<description>"').option("--mood <mood>", "mood hint").option("--ar <ratio>", 'aspect ratio, e.g. "16:9", "9:16"').option("--project <id>", "build into an existing project instead of creating one").option("--image-model <id>", "image model for visual-asset images").option("--no-asset-images", "skip generating visual-asset reference images").option("--shots", "also batch-generate every shot image (spends credits per shot)").option("--grid", "with --shots: grid mode for stronger cross-shot consistency").option("--script-only", "stop at the script (script-stage project, no storyboard)").action(async function(ideaWords) {
|
|
1778
|
+
const ctx = buildContext(this);
|
|
1779
|
+
const opts = this.opts();
|
|
1780
|
+
const idea = ideaWords.join(" ");
|
|
1781
|
+
capture("cli_create", { script_only: Boolean(opts.scriptOnly), shots: Boolean(opts.shots) });
|
|
1782
|
+
const tool = opts.scriptOnly ? "generate_script_from_idea" : "generate_storyboard_from_idea";
|
|
1783
|
+
const spin = spinner(
|
|
1784
|
+
ctx.out,
|
|
1785
|
+
opts.scriptOnly ? "Generating script\u2026" : "Building project (script \u2192 assets \u2192 storyboard)\u2026"
|
|
1786
|
+
);
|
|
1787
|
+
let result;
|
|
1788
|
+
try {
|
|
1789
|
+
result = await ctx.client.callTool(
|
|
1790
|
+
tool,
|
|
1791
|
+
compact({
|
|
1792
|
+
idea,
|
|
1793
|
+
title: opts.title,
|
|
1794
|
+
duration: opts.duration,
|
|
1795
|
+
style: opts.style,
|
|
1796
|
+
mood: opts.mood,
|
|
1797
|
+
aspect_ratio: opts.scriptOnly ? void 0 : opts.ar,
|
|
1798
|
+
project_id: opts.project,
|
|
1799
|
+
image_model: opts.scriptOnly ? void 0 : opts.imageModel,
|
|
1800
|
+
generate_asset_images: opts.scriptOnly ? void 0 : opts.assetImages,
|
|
1801
|
+
generate_shot_images: opts.scriptOnly ? void 0 : opts.shots || void 0,
|
|
1802
|
+
grid: opts.scriptOnly ? void 0 : opts.grid || void 0
|
|
1803
|
+
})
|
|
1804
|
+
);
|
|
1805
|
+
} catch (err) {
|
|
1806
|
+
if (err?.name === "TimeoutError" || /timed? ?out|abort/i.test(String(err?.message))) {
|
|
1807
|
+
throw new CliError(
|
|
1808
|
+
"The request timed out, but the project was likely still created server-side.",
|
|
1809
|
+
EXIT.ERROR,
|
|
1810
|
+
"Run `videodraft projects list`, take the most recent project, and resume with `videodraft create ... --project <id>` (or continue with `videodraft shots <id>`). Do not re-run create from scratch."
|
|
1811
|
+
);
|
|
1812
|
+
}
|
|
1813
|
+
throw err;
|
|
1814
|
+
} finally {
|
|
1815
|
+
spin.stop();
|
|
1816
|
+
}
|
|
1817
|
+
emit(ctx.out, result, (o) => {
|
|
1818
|
+
note(o, fmt.green(o, `Project ${result?.project_id ?? "created"}.`));
|
|
1819
|
+
if (result?.urls?.storyboard) note(o, ` ${result.urls.storyboard}`);
|
|
1820
|
+
if (result?.summary) note(o, fmt.dim(o, String(result.summary).slice(0, 400)));
|
|
1821
|
+
const shotJobs = result?.shot_image_job_ids ?? [];
|
|
1822
|
+
if (shotJobs.length > 0) {
|
|
1823
|
+
note(o, fmt.dim(o, `Shot image jobs: ${shotJobs.join(", ")} \u2014 poll with videodraft status <job>`));
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
});
|
|
1827
|
+
program.command("shots <project_id>").description("Batch-generate the storyboard shot images (spends credits per shot)").option("--scene <n>", "0-based scene index (omit for all scenes)").option("--model <id>", "image model id").option("--ar <ratio>", "aspect ratio override").option("--grid", "grid mode (stronger cross-shot consistency)").option("--regenerate-all", "replace existing shot images too").option("--no-wait", "submit and return job ids immediately").option("--estimate", "estimate the cost and exit (spends nothing)").action(async function(projectId) {
|
|
1828
|
+
const ctx = buildContext(this);
|
|
1829
|
+
const opts = this.opts();
|
|
1830
|
+
if (opts.estimate) {
|
|
1831
|
+
const project = await ctx.client.callTool("get_project", {
|
|
1832
|
+
project_id: projectId,
|
|
1833
|
+
view: "summary"
|
|
1834
|
+
});
|
|
1835
|
+
const costs = await ctx.client.callTool(
|
|
1836
|
+
"get_model_costs",
|
|
1837
|
+
compact({ model_id: opts.model, type: "image" })
|
|
1838
|
+
);
|
|
1839
|
+
emit(ctx.out, {
|
|
1840
|
+
project_scenes: project?.storyboard?.scenes?.length ?? project?.scenes?.length,
|
|
1841
|
+
per_image_costs: costs,
|
|
1842
|
+
note: "Total \u2248 shots missing images \xD7 per-image cost (+1 grid image per scene in grid mode). No credits were spent."
|
|
1843
|
+
});
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
capture("cli_shots", { grid: Boolean(opts.grid) });
|
|
1847
|
+
const submitted = await ctx.client.callTool(
|
|
1848
|
+
"generate_shot_images",
|
|
1849
|
+
compact({
|
|
1850
|
+
project_id: projectId,
|
|
1851
|
+
scene_index: opts.scene !== void 0 ? Number(opts.scene) : void 0,
|
|
1852
|
+
model: opts.model,
|
|
1853
|
+
aspect_ratio: opts.ar,
|
|
1854
|
+
grid: opts.grid || void 0,
|
|
1855
|
+
regenerate_all: opts.regenerateAll || void 0
|
|
1856
|
+
})
|
|
1857
|
+
);
|
|
1858
|
+
const jobIds = submitted?.job_ids ?? (submitted?.job_id ? [submitted.job_id] : []);
|
|
1859
|
+
if (opts.wait === false || jobIds.length === 0) {
|
|
1860
|
+
emit(ctx.out, submitted, (o) => {
|
|
1861
|
+
note(o, `Submitted ${jobIds.length || "?"} job(s).`);
|
|
1862
|
+
for (const id of jobIds) note(o, fmt.dim(o, ` videodraft status ${id}`));
|
|
1863
|
+
});
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
const spin = spinner(ctx.out, `Generating shot images (${jobIds.length} job(s))\u2026`);
|
|
1867
|
+
try {
|
|
1868
|
+
const resultMap = await pollGenerationsBatch(ctx.client, jobIds, {
|
|
1869
|
+
intervalMs: ctx.intervalMs,
|
|
1870
|
+
timeoutMs: ctx.timeoutMs,
|
|
1871
|
+
adaptive: ctx.adaptive,
|
|
1872
|
+
onTick: (progress) => spin.update(`Generating shot images \u2014 ${progress}`)
|
|
1873
|
+
});
|
|
1874
|
+
const results = jobIds.map((id) => resultMap.get(id));
|
|
1875
|
+
spin.stop();
|
|
1876
|
+
const failed = results.filter((r) => r.status === "failed").length;
|
|
1877
|
+
emit(
|
|
1878
|
+
ctx.out,
|
|
1879
|
+
{ job_ids: jobIds, results: results.map((r) => ({ status: r.status, outputs: r.outputUrls })) },
|
|
1880
|
+
(o) => {
|
|
1881
|
+
note(
|
|
1882
|
+
o,
|
|
1883
|
+
failed === 0 ? fmt.green(o, `All ${results.length} job(s) completed \u2014 images are on the shot cards.`) : fmt.red(o, `${failed}/${results.length} job(s) failed.`)
|
|
1884
|
+
);
|
|
1885
|
+
}
|
|
1886
|
+
);
|
|
1887
|
+
if (failed > 0) process.exitCode = 1;
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
spin.stop();
|
|
1890
|
+
throw err;
|
|
1891
|
+
}
|
|
1892
|
+
});
|
|
1893
|
+
program.command("produce <project_id>").description("Produce the storyboard into production \u2014 animatic (default) or full_video AI Production").option("--mode <mode>", "animatic (slideshow, default) | full_video (one Seedance 2 video per scene)").option("--no-auto-videos", "full_video: set up scene videos but don't submit them (no credits spent)").option("--no-voiceover", "skip per-scene voiceovers + captions").option("--no-video-prompts", "skip advisory per-shot motion prompts").option("--shot-duration <seconds>", "per-shot clip length for silent/no-voiceover scenes (default 3)").option("--voice <id>", "TTS voice id for voiceovers").option("--language <bcp47>", "voiceover + caption language").option("--captions", "force burn captions").option("--no-captions", "force no captions (default follows voiceover)").action(async function(projectId) {
|
|
1894
|
+
const ctx = buildContext(this);
|
|
1895
|
+
const opts = this.opts();
|
|
1896
|
+
const captionsSrc = this.getOptionValueSource("captions");
|
|
1897
|
+
capture("cli_produce", { mode: opts.mode ?? "animatic" });
|
|
1898
|
+
const spin = spinner(
|
|
1899
|
+
ctx.out,
|
|
1900
|
+
opts.mode === "full_video" ? "Producing (full_video: composing scenes \u2192 submitting Seedance videos)\u2026" : "Producing project (voiceovers \u2192 captions \u2192 timeline)\u2026"
|
|
1901
|
+
);
|
|
1902
|
+
let result;
|
|
1903
|
+
try {
|
|
1904
|
+
result = await ctx.client.callTool(
|
|
1905
|
+
"produce_project",
|
|
1906
|
+
compact({
|
|
1907
|
+
project_id: projectId,
|
|
1908
|
+
mode: opts.mode,
|
|
1909
|
+
// negatable flags default true; only forward an explicit opt-out.
|
|
1910
|
+
auto_generate_videos: opts.autoVideos === false ? false : void 0,
|
|
1911
|
+
include_voiceover: opts.voiceover === false ? false : void 0,
|
|
1912
|
+
generate_video_prompts: opts.videoPrompts === false ? false : void 0,
|
|
1913
|
+
shot_duration: opts.shotDuration ? Number(opts.shotDuration) : void 0,
|
|
1914
|
+
voice_id: opts.voice,
|
|
1915
|
+
language: opts.language,
|
|
1916
|
+
show_captions: captionsSrc === "cli" ? opts.captions : void 0
|
|
1917
|
+
})
|
|
1918
|
+
);
|
|
1919
|
+
} finally {
|
|
1920
|
+
spin.stop();
|
|
1921
|
+
}
|
|
1922
|
+
const pendingVideos = result?.pending_scene_video_generations ?? result?.pending_generation_ids ?? [];
|
|
1923
|
+
emit(ctx.out, result, (o) => {
|
|
1924
|
+
if (result?.status === "generating_shot_images") {
|
|
1925
|
+
note(o, fmt.yellow(o, "Shot images are still generating \u2014 poll the job ids below, then re-run produce."));
|
|
1926
|
+
for (const id of result?.shot_image_job_ids ?? result?.job_ids ?? [])
|
|
1927
|
+
note(o, fmt.dim(o, ` videodraft status ${id}`));
|
|
1928
|
+
} else if (opts.mode === "full_video" && pendingVideos.length > 0) {
|
|
1929
|
+
note(o, fmt.green(o, `Producing ${pendingVideos.length} scene video(s).`));
|
|
1930
|
+
note(o, fmt.dim(o, "Poll: videodraft generations \u2192 then: videodraft finalize " + projectId + " \u2192 videodraft export " + projectId));
|
|
1931
|
+
} else {
|
|
1932
|
+
note(o, fmt.green(o, "Produced. Next: videodraft export " + projectId));
|
|
1933
|
+
}
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1936
|
+
program.command("finalize <project_id>").description("Swap completed full_video scene videos into the timeline (after produce --mode full_video)").action(async function(projectId) {
|
|
1937
|
+
const ctx = buildContext(this);
|
|
1938
|
+
const result = await ctx.client.callTool("finalize_scene_videos", { project_id: projectId });
|
|
1939
|
+
emit(ctx.out, result, (o) => {
|
|
1940
|
+
note(
|
|
1941
|
+
o,
|
|
1942
|
+
fmt.green(
|
|
1943
|
+
o,
|
|
1944
|
+
`Finalized ${result?.finalized ?? 0}, failed ${result?.failed ?? 0}, still pending ${result?.still_pending ?? result?.pending_remaining ?? 0}.`
|
|
1945
|
+
)
|
|
1946
|
+
);
|
|
1947
|
+
if ((result?.pending_remaining ?? result?.still_pending ?? 0) > 0) {
|
|
1948
|
+
note(o, fmt.dim(o, "Some clips are still rendering \u2014 re-run finalize later (it's idempotent)."));
|
|
1949
|
+
}
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
program.command("attach <project_id>").description("Place a clip/image onto a storyboard shot (e.g. a generated video into the timeline)").requiredOption("--scene <n>", "0-based scene index").requiredOption("--shot <n>", "0-based shot index").requiredOption("--media <url|file>", "media to attach (local files are uploaded)").option("--type <type>", "image | video (default image)").option("--duration <seconds>", "clip duration in seconds (recommended for video)").option("--thumbnail <url|file>", "optional thumbnail").action(async function(projectId) {
|
|
1953
|
+
const ctx = buildContext(this);
|
|
1954
|
+
const opts = this.opts();
|
|
1955
|
+
const [mediaUrl, thumbUrl] = await Promise.all([
|
|
1956
|
+
resolveMedia(ctx, opts.media),
|
|
1957
|
+
opts.thumbnail ? resolveMedia(ctx, opts.thumbnail) : void 0
|
|
1958
|
+
]);
|
|
1959
|
+
capture("cli_attach", { type: opts.type ?? "image" });
|
|
1960
|
+
const result = await ctx.client.callTool(
|
|
1961
|
+
"attach_media_to_shot",
|
|
1962
|
+
compact({
|
|
1963
|
+
project_id: projectId,
|
|
1964
|
+
scene_index: Number(opts.scene),
|
|
1965
|
+
shot_index: Number(opts.shot),
|
|
1966
|
+
media_url: mediaUrl,
|
|
1967
|
+
thumbnail_url: thumbUrl,
|
|
1968
|
+
media_type: opts.type,
|
|
1969
|
+
duration_seconds: opts.duration ? Number(opts.duration) : void 0
|
|
1970
|
+
})
|
|
1971
|
+
);
|
|
1972
|
+
emit(ctx.out, result, (o) => note(o, fmt.green(o, `Attached to scene ${opts.scene}, shot ${opts.shot}.`)));
|
|
1973
|
+
});
|
|
1974
|
+
program.command("export <project_id>").description("Render the final MP4 (project must be produced)").option("--captions", "burn captions into the render").option("--no-captions", "render without captions").option("--download <path>", "download the MP4 when finished").option("--no-wait", "start the export and return the export id").action(async function(projectId) {
|
|
1975
|
+
const ctx = buildContext(this);
|
|
1976
|
+
const opts = this.opts();
|
|
1977
|
+
capture("cli_export");
|
|
1978
|
+
const started = await ctx.client.callTool(
|
|
1979
|
+
"export_video",
|
|
1980
|
+
compact({ project_id: projectId, show_captions: opts.captions })
|
|
1981
|
+
);
|
|
1982
|
+
const exportId = started?.export_id;
|
|
1983
|
+
if (opts.wait === false) {
|
|
1984
|
+
emit(ctx.out, started, (o) => {
|
|
1985
|
+
note(o, `Export started${exportId ? ` \u2014 ${exportId}` : ""}.`);
|
|
1986
|
+
note(o, fmt.dim(o, `Check with: videodraft export-status ${exportId ?? projectId}`));
|
|
1987
|
+
});
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const spin = spinner(ctx.out, "Rendering MP4\u2026");
|
|
1991
|
+
try {
|
|
1992
|
+
const result = await pollExport(
|
|
1993
|
+
ctx.client,
|
|
1994
|
+
{ exportId, projectId },
|
|
1995
|
+
{
|
|
1996
|
+
intervalMs: Math.max(ctx.intervalMs, 5e3),
|
|
1997
|
+
timeoutMs: Math.max(ctx.timeoutMs, 12e5),
|
|
1998
|
+
adaptive: ctx.adaptive,
|
|
1999
|
+
onTick: (status) => spin.update(`Rendering MP4 \u2014 ${status}`)
|
|
2000
|
+
}
|
|
2001
|
+
);
|
|
2002
|
+
spin.stop();
|
|
2003
|
+
if (result.status === "failed" || !result.videoUrl) {
|
|
2004
|
+
emit(ctx.out, result.payload, (o) => note(o, fmt.red(o, "Export failed.")));
|
|
2005
|
+
process.exitCode = 1;
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
let downloaded;
|
|
2009
|
+
if (opts.download) {
|
|
2010
|
+
downloaded = await downloadOutputs([result.videoUrl], opts.download, {
|
|
2011
|
+
job_id: exportId ?? projectId,
|
|
2012
|
+
name: "export"
|
|
2013
|
+
});
|
|
2014
|
+
}
|
|
2015
|
+
emit(ctx.out, { export_id: exportId, video_url: result.videoUrl, downloaded_files: downloaded }, (o) => {
|
|
2016
|
+
note(o, fmt.green(o, "Export finished."));
|
|
2017
|
+
process.stdout.write(`${result.videoUrl}
|
|
2018
|
+
`);
|
|
2019
|
+
for (const f of downloaded ?? []) note(o, fmt.dim(o, `saved ${f.path}`));
|
|
2020
|
+
});
|
|
2021
|
+
} catch (err) {
|
|
2022
|
+
spin.stop();
|
|
2023
|
+
throw err;
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
program.command("export-status <id>").description("Check a video export \u2014 pass the export_id (or --project to pass a project id)").option("--project", "treat <id> as a project id (checks its latest export)").option("--wait <seconds>", "block up to N seconds (max 240), re-polling until terminal").action(async function(ref) {
|
|
2027
|
+
const ctx = buildContext(this);
|
|
2028
|
+
const opts = this.opts();
|
|
2029
|
+
const result = await ctx.client.callTool(
|
|
2030
|
+
"check_export_status",
|
|
2031
|
+
compact({
|
|
2032
|
+
export_id: opts.project ? void 0 : ref,
|
|
2033
|
+
project_id: opts.project ? ref : void 0,
|
|
2034
|
+
wait_seconds: opts.wait ? Number(opts.wait) : void 0
|
|
2035
|
+
})
|
|
2036
|
+
);
|
|
2037
|
+
emit(ctx.out, result);
|
|
2038
|
+
});
|
|
2039
|
+
program.command("video-prompts <project_id>").description("Generate advisory per-shot motion/video prompts for a project").option("--ar <ratio>", "aspect ratio").option("--instructions <text>", "authoring instructions").option("--has-voiceover", "tell the generator the project has a voiceover track").option("--video-audio", "tell the generator that generated videos may include native audio").option("--has-bgm", "tell the generator the project has background music").action(async function(projectId) {
|
|
2040
|
+
const ctx = buildContext(this);
|
|
2041
|
+
const opts = this.opts();
|
|
2042
|
+
const result = await ctx.client.callTool(
|
|
2043
|
+
"generate_video_prompts",
|
|
2044
|
+
compact({
|
|
2045
|
+
project_id: projectId,
|
|
2046
|
+
aspect_ratio: opts.ar,
|
|
2047
|
+
instructions: opts.instructions,
|
|
2048
|
+
has_voiceover: opts.hasVoiceover || void 0,
|
|
2049
|
+
generate_video_audio: opts.videoAudio || void 0,
|
|
2050
|
+
has_background_music: opts.hasBgm || void 0
|
|
2051
|
+
})
|
|
2052
|
+
);
|
|
2053
|
+
emit(ctx.out, result);
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// src/commands/jobs.ts
|
|
2058
|
+
function registerJobCommands(program) {
|
|
2059
|
+
program.command("status <job_id>").description("Check an async generation job once (no waiting)").option("--project <id>", "project id for fallback lookups").option("--scene-id <id>", "scene id for fallback lookups").action(async function(jobId) {
|
|
2060
|
+
const ctx = buildContext(this);
|
|
2061
|
+
const opts = this.opts();
|
|
2062
|
+
const result = await ctx.client.callTool(
|
|
2063
|
+
"check_generation_status",
|
|
2064
|
+
compact({ job_id: jobId, project_id: opts.project, scene_id: opts.sceneId })
|
|
2065
|
+
);
|
|
2066
|
+
emit(ctx.out, result, (o) => {
|
|
2067
|
+
note(o, `${jobId}: ${result?.status ?? "unknown"}`);
|
|
2068
|
+
for (const url of extractOutputUrls(result)) process.stdout.write(`${url}
|
|
2069
|
+
`);
|
|
2070
|
+
});
|
|
2071
|
+
});
|
|
2072
|
+
program.command("wait <job_ids...>").description(
|
|
2073
|
+
"Block until generation job(s) finish. Multiple ids poll in ONE process with ONE batched request per tick \u2014 prefer this over parallel `wait` processes"
|
|
2074
|
+
).option("--download <path>", "download outputs (template: {job_id} {index} {ext})").action(async function(jobIds) {
|
|
2075
|
+
const ctx = buildContext(this);
|
|
2076
|
+
const opts = this.opts();
|
|
2077
|
+
if (jobIds.length > 1 && typeof opts.download === "string" && !opts.download.includes("{job_id}") && /\.[A-Za-z0-9]+$/.test(opts.download)) {
|
|
2078
|
+
throw new CliError(
|
|
2079
|
+
`--download "${opts.download}" would be overwritten by each job. Include {job_id} in the template (e.g. "out/{job_id}_{index}.{ext}") or pass a directory.`,
|
|
2080
|
+
EXIT.USAGE
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
const label = jobIds.length === 1 ? `job ${jobIds[0]}` : `${jobIds.length} jobs`;
|
|
2084
|
+
const spin = spinner(ctx.out, `Waiting for ${label}\u2026`);
|
|
2085
|
+
try {
|
|
2086
|
+
const results = await pollGenerationsBatch(ctx.client, jobIds, {
|
|
2087
|
+
intervalMs: ctx.intervalMs,
|
|
2088
|
+
timeoutMs: ctx.timeoutMs,
|
|
2089
|
+
adaptive: ctx.adaptive,
|
|
2090
|
+
onTick: (progress) => spin.update(`Waiting for ${label} \u2014 ${progress}`)
|
|
2091
|
+
});
|
|
2092
|
+
spin.stop();
|
|
2093
|
+
const rows = [];
|
|
2094
|
+
let failures = 0;
|
|
2095
|
+
for (const jobId of jobIds) {
|
|
2096
|
+
const result = results.get(jobId);
|
|
2097
|
+
let downloaded;
|
|
2098
|
+
if (result.status === "failed") {
|
|
2099
|
+
failures++;
|
|
2100
|
+
} else if (opts.download && result.outputUrls.length > 0) {
|
|
2101
|
+
downloaded = await downloadOutputs(result.outputUrls, opts.download, { job_id: jobId });
|
|
2102
|
+
}
|
|
2103
|
+
rows.push({
|
|
2104
|
+
job_id: jobId,
|
|
2105
|
+
status: result.status,
|
|
2106
|
+
outputs: result.outputUrls,
|
|
2107
|
+
downloaded_files: downloaded,
|
|
2108
|
+
...result.status === "failed" ? { error: result.payload?.error } : {}
|
|
2109
|
+
});
|
|
2110
|
+
}
|
|
2111
|
+
emit(ctx.out, jobIds.length === 1 ? rows[0] : rows, (o) => {
|
|
2112
|
+
for (const row of rows) {
|
|
2113
|
+
if (row.status === "failed") {
|
|
2114
|
+
note(o, fmt.red(o, `${row.job_id} failed: ${row.error ?? "unknown"}`));
|
|
2115
|
+
continue;
|
|
2116
|
+
}
|
|
2117
|
+
for (const url of row.outputs) process.stdout.write(`${url}
|
|
2118
|
+
`);
|
|
2119
|
+
for (const f of row.downloaded_files ?? []) note(o, fmt.dim(o, `saved ${f.path}`));
|
|
2120
|
+
}
|
|
2121
|
+
});
|
|
2122
|
+
if (failures > 0) process.exitCode = 1;
|
|
2123
|
+
} catch (err) {
|
|
2124
|
+
spin.stop();
|
|
2125
|
+
throw err;
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
program.command("generations").description("List recent + in-flight generations").option("--type <type>", "image | video").option("--status <status>", "pending | processing | completed | failed").option("--limit <n>", "max rows (default 30)").action(async function() {
|
|
2129
|
+
const ctx = buildContext(this);
|
|
2130
|
+
const opts = this.opts();
|
|
2131
|
+
const result = await ctx.client.callTool(
|
|
2132
|
+
"list_recent_generations",
|
|
2133
|
+
compact({
|
|
2134
|
+
type: opts.type,
|
|
2135
|
+
status: opts.status,
|
|
2136
|
+
limit: opts.limit ? Number(opts.limit) : void 0
|
|
2137
|
+
})
|
|
2138
|
+
);
|
|
2139
|
+
const rows = result?.generations ?? result?.items ?? [];
|
|
2140
|
+
emit(ctx.out, result, (o) => {
|
|
2141
|
+
table(
|
|
2142
|
+
o,
|
|
2143
|
+
["id", "type", "status", "model", "created"],
|
|
2144
|
+
rows.map((g) => [
|
|
2145
|
+
String(g.id ?? g.job_id ?? ""),
|
|
2146
|
+
String(g.type ?? ""),
|
|
2147
|
+
String(g.status ?? ""),
|
|
2148
|
+
String(g.model ?? "").slice(0, 24),
|
|
2149
|
+
String(g.created_at ?? g.createdAt ?? "").slice(0, 19)
|
|
2150
|
+
])
|
|
2151
|
+
);
|
|
2152
|
+
if (result?.inFlight !== void 0) note(o, fmt.dim(o, `In flight: ${result.inFlight}`));
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// src/commands/media.ts
|
|
2158
|
+
function registerMediaCommands(program) {
|
|
2159
|
+
program.command("upload <file>").description("Upload a local image/video/audio file; prints the public CDN URL").option("--content-type <mime>", "MIME override when the extension is ambiguous").action(async function(file) {
|
|
2160
|
+
const ctx = buildContext(this);
|
|
2161
|
+
capture("cli_upload");
|
|
2162
|
+
const result = await uploadFile(ctx.client, file, {
|
|
2163
|
+
contentType: this.opts().contentType
|
|
2164
|
+
});
|
|
2165
|
+
emit(ctx.out, result, (o) => {
|
|
2166
|
+
process.stdout.write(`${result.url}
|
|
2167
|
+
`);
|
|
2168
|
+
note(o, fmt.dim(o, "Use this URL anywhere a public media URL is accepted (--ref, --start-image, ...)"));
|
|
2169
|
+
});
|
|
2170
|
+
});
|
|
2171
|
+
const media = program.command("media").description("Your media library");
|
|
2172
|
+
media.command("list", { isDefault: true }).description("List media library items").option("--type <type>", "image | video | audio").option("--limit <n>", "max rows").option("--offset <n>", "pagination offset").action(async function() {
|
|
2173
|
+
const ctx = buildContext(this);
|
|
2174
|
+
const opts = this.opts();
|
|
2175
|
+
const result = await ctx.client.callTool(
|
|
2176
|
+
"list_media",
|
|
2177
|
+
compact({
|
|
2178
|
+
type: opts.type,
|
|
2179
|
+
limit: opts.limit ? Number(opts.limit) : void 0,
|
|
2180
|
+
offset: opts.offset ? Number(opts.offset) : void 0
|
|
2181
|
+
})
|
|
2182
|
+
);
|
|
2183
|
+
const rows = result?.media ?? result?.items ?? [];
|
|
2184
|
+
emit(ctx.out, result, (o) => {
|
|
2185
|
+
table(
|
|
2186
|
+
o,
|
|
2187
|
+
["type", "url", "created"],
|
|
2188
|
+
rows.map((m) => [
|
|
2189
|
+
String(m.type ?? m.media_type ?? ""),
|
|
2190
|
+
String(m.url ?? m.cdn_url ?? m.public_url ?? "").slice(0, 80),
|
|
2191
|
+
String(m.createdAt ?? m.created_at ?? "").slice(0, 19)
|
|
2192
|
+
])
|
|
2193
|
+
);
|
|
2194
|
+
});
|
|
2195
|
+
});
|
|
2196
|
+
program.command("describe <url|file>").description("Describe an image with a vision model (local files are uploaded first)").action(async function(source) {
|
|
2197
|
+
const ctx = buildContext(this);
|
|
2198
|
+
let url = source;
|
|
2199
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(source)) {
|
|
2200
|
+
url = (await uploadFile(ctx.client, source)).url;
|
|
2201
|
+
}
|
|
2202
|
+
capture("cli_describe");
|
|
2203
|
+
const result = await ctx.client.callTool("describe_image", { image_url: url });
|
|
2204
|
+
emit(ctx.out, result, (o) => {
|
|
2205
|
+
const text = result?.description ?? result?.text ?? JSON.stringify(result);
|
|
2206
|
+
process.stdout.write(`${text}
|
|
2207
|
+
`);
|
|
2208
|
+
});
|
|
2209
|
+
});
|
|
2210
|
+
program.command("download <url>").description("Download a (CDN) URL to a local file").option("-o, --output <path>", "output file or directory (default: basename in cwd)").action(async function(url) {
|
|
2211
|
+
const ctx = buildContext(this);
|
|
2212
|
+
const template = this.opts().output ?? ".";
|
|
2213
|
+
const dest = renderTemplate(template, {
|
|
2214
|
+
index: 0,
|
|
2215
|
+
ext: extFromUrl(url),
|
|
2216
|
+
name: new URL(url).pathname.split("/").pop()?.replace(/\.[^.]+$/, "") ?? "download"
|
|
2217
|
+
});
|
|
2218
|
+
const file = await downloadUrl(url, dest);
|
|
2219
|
+
emit(ctx.out, file, (o) => note(o, fmt.green(o, `saved ${file.path} (${file.bytes} bytes)`)));
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// src/commands/tools.ts
|
|
2224
|
+
function coerceArgValue(raw) {
|
|
2225
|
+
const trimmed = raw.trim();
|
|
2226
|
+
if (trimmed === "") return "";
|
|
2227
|
+
try {
|
|
2228
|
+
return JSON.parse(trimmed);
|
|
2229
|
+
} catch {
|
|
2230
|
+
return raw;
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
function parseKeyValueArgs(pairs) {
|
|
2234
|
+
const args = {};
|
|
2235
|
+
for (const pair of pairs) {
|
|
2236
|
+
const eq = pair.indexOf("=");
|
|
2237
|
+
if (eq <= 0) {
|
|
2238
|
+
throw new CliError(`--arg expects key=value, got: ${pair}`, EXIT.USAGE);
|
|
2239
|
+
}
|
|
2240
|
+
args[pair.slice(0, eq)] = coerceArgValue(pair.slice(eq + 1));
|
|
2241
|
+
}
|
|
2242
|
+
return args;
|
|
2243
|
+
}
|
|
2244
|
+
function registerToolCommands(program) {
|
|
2245
|
+
const tools = program.command("tools").description("Inspect the full MCP tool catalog");
|
|
2246
|
+
tools.command("list", { isDefault: true }).description("List every available tool").action(async function() {
|
|
2247
|
+
const ctx = buildContext(this);
|
|
2248
|
+
const list = await ctx.client.listTools();
|
|
2249
|
+
emit(ctx.out, list.map((t) => ({ name: t.name, description: t.description })), (o) => {
|
|
2250
|
+
table(
|
|
2251
|
+
o,
|
|
2252
|
+
["name", "description"],
|
|
2253
|
+
list.map((t) => [t.name, t.description.split(/[.!]\s/)[0].slice(0, 90)])
|
|
2254
|
+
);
|
|
2255
|
+
note(o, fmt.dim(o, `
|
|
2256
|
+
${list.length} tools. Inspect one: videodraft tools schema <name>`));
|
|
2257
|
+
});
|
|
2258
|
+
});
|
|
2259
|
+
tools.command("schema <name>").description("Show a tool's description and JSON input schema").action(async function(name) {
|
|
2260
|
+
const ctx = buildContext(this);
|
|
2261
|
+
const list = await ctx.client.listTools();
|
|
2262
|
+
const tool = list.find((t) => t.name === name);
|
|
2263
|
+
if (!tool) {
|
|
2264
|
+
throw new CliError(
|
|
2265
|
+
`Unknown tool: ${name}. List tools with: videodraft tools list`,
|
|
2266
|
+
EXIT.USAGE
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
emit(ctx.out, tool);
|
|
2270
|
+
});
|
|
2271
|
+
program.command("call <tool>").description("Call any MCP tool directly (full API coverage)").option("--args <json>", "arguments as a JSON object").option("--arg <key=value>", "single argument (repeatable; values JSON-coerced)", collect, []).option("--stdin", "read the JSON arguments object from stdin").action(async function(toolName) {
|
|
2272
|
+
const ctx = buildContext(this);
|
|
2273
|
+
const opts = this.opts();
|
|
2274
|
+
let args = {};
|
|
2275
|
+
if (opts.stdin) {
|
|
2276
|
+
const chunks = [];
|
|
2277
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
2278
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
2279
|
+
if (raw) args = JSON.parse(raw);
|
|
2280
|
+
}
|
|
2281
|
+
if (opts.args) {
|
|
2282
|
+
let parsed;
|
|
2283
|
+
try {
|
|
2284
|
+
parsed = JSON.parse(opts.args);
|
|
2285
|
+
} catch (err) {
|
|
2286
|
+
throw new CliError(`--args is not valid JSON: ${err?.message}`, EXIT.USAGE);
|
|
2287
|
+
}
|
|
2288
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
2289
|
+
throw new CliError("--args must be a JSON object", EXIT.USAGE);
|
|
2290
|
+
}
|
|
2291
|
+
args = { ...args, ...parsed };
|
|
2292
|
+
}
|
|
2293
|
+
if (opts.arg.length > 0) {
|
|
2294
|
+
args = { ...args, ...parseKeyValueArgs(opts.arg) };
|
|
2295
|
+
}
|
|
2296
|
+
capture("cli_call", { tool: toolName });
|
|
2297
|
+
const result = await ctx.client.callTool(toolName, args);
|
|
2298
|
+
emit(ctx.out, result);
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
// src/commands/avatar.ts
|
|
2303
|
+
import fs6 from "fs";
|
|
2304
|
+
function registerAvatarCommands(program) {
|
|
2305
|
+
const avatar = program.command("avatar").description("Avatar / talking-head videos");
|
|
2306
|
+
avatar.command("script <idea...>").description("Generate a ~30s spoken script for an avatar video (free)").option("--style <style>", "narrative | ad-style | casual-talk | promotional | educational").action(async function(ideaWords) {
|
|
2307
|
+
const ctx = buildContext(this);
|
|
2308
|
+
const result = await ctx.client.callTool(
|
|
2309
|
+
"generate_avatar_script",
|
|
2310
|
+
compact({ idea: ideaWords.join(" "), style: this.opts().style })
|
|
2311
|
+
);
|
|
2312
|
+
emit(ctx.out, result);
|
|
2313
|
+
});
|
|
2314
|
+
avatar.command("create <image_url_or_file>").description("Create an avatar video from an image + script (does not render yet)").requiredOption("--script <text>", "the spoken script (~30s)").option("--voice <id>", "TTS voice id").option("--name <name>", "avatar display name").option("--ar <ratio>", 'aspect ratio (default "9:16")').option("--language <bcp47>", "target language").action(async function(source) {
|
|
2315
|
+
const ctx = buildContext(this);
|
|
2316
|
+
const opts = this.opts();
|
|
2317
|
+
let imageUrl = source;
|
|
2318
|
+
if (!/^https?:\/\//.test(source) && fs6.existsSync(source)) {
|
|
2319
|
+
const uploaded = await uploadFile(ctx.client, source);
|
|
2320
|
+
imageUrl = uploaded.url;
|
|
2321
|
+
}
|
|
2322
|
+
capture("cli_avatar", { step: "create" });
|
|
2323
|
+
const result = await ctx.client.callTool(
|
|
2324
|
+
"create_avatar_video",
|
|
2325
|
+
compact({
|
|
2326
|
+
script: opts.script,
|
|
2327
|
+
character_image_url: imageUrl,
|
|
2328
|
+
voice_id: opts.voice,
|
|
2329
|
+
character_name: opts.name,
|
|
2330
|
+
aspect_ratio: opts.ar,
|
|
2331
|
+
target_language: opts.language
|
|
2332
|
+
})
|
|
2333
|
+
);
|
|
2334
|
+
emit(ctx.out, result, (o) => {
|
|
2335
|
+
note(o, fmt.green(o, `Avatar video ${result?.avatar_video_id ?? "created"}.`));
|
|
2336
|
+
note(o, fmt.dim(o, `Render (paid): videodraft avatar render ${result?.avatar_video_id}`));
|
|
2337
|
+
});
|
|
2338
|
+
});
|
|
2339
|
+
avatar.command("render <avatar_video_id>").description("Render an avatar video (spends credits; waits by default)").option("--resolution <res>", '"480p" | "720p" (default 720p)').option("--no-wait", "queue the render and return immediately").action(async function(avatarVideoId) {
|
|
2340
|
+
const ctx = buildContext(this);
|
|
2341
|
+
const opts = this.opts();
|
|
2342
|
+
capture("cli_avatar", { step: "render" });
|
|
2343
|
+
const started = await ctx.client.callTool(
|
|
2344
|
+
"render_avatar_video",
|
|
2345
|
+
compact({ avatar_video_id: avatarVideoId, resolution: opts.resolution })
|
|
2346
|
+
);
|
|
2347
|
+
if (opts.wait === false) {
|
|
2348
|
+
emit(
|
|
2349
|
+
ctx.out,
|
|
2350
|
+
started,
|
|
2351
|
+
(o) => note(o, `Render queued. Check with: videodraft avatar get ${avatarVideoId}`)
|
|
2352
|
+
);
|
|
2353
|
+
return;
|
|
2354
|
+
}
|
|
2355
|
+
const spin = spinner(ctx.out, "Rendering avatar video\u2026");
|
|
2356
|
+
const deadline = Date.now() + ctx.timeoutMs;
|
|
2357
|
+
try {
|
|
2358
|
+
for (; ; ) {
|
|
2359
|
+
const status = await ctx.client.callTool("get_avatar_video", {
|
|
2360
|
+
avatar_video_id: avatarVideoId
|
|
2361
|
+
});
|
|
2362
|
+
const exportStatus = String(status?.export_status ?? "unknown");
|
|
2363
|
+
spin.update(`Rendering avatar video \u2014 ${exportStatus}`);
|
|
2364
|
+
if (exportStatus === "completed") {
|
|
2365
|
+
spin.stop();
|
|
2366
|
+
emit(ctx.out, status, (o) => {
|
|
2367
|
+
note(o, fmt.green(o, "Avatar render completed."));
|
|
2368
|
+
if (status?.video_url) process.stdout.write(`${status.video_url}
|
|
2369
|
+
`);
|
|
2370
|
+
});
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
if (exportStatus === "failed") {
|
|
2374
|
+
spin.stop();
|
|
2375
|
+
emit(ctx.out, status, (o) => note(o, fmt.red(o, "Avatar render failed.")));
|
|
2376
|
+
process.exitCode = 1;
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
if (Date.now() > deadline) {
|
|
2380
|
+
throw new TimeoutError(
|
|
2381
|
+
`Timed out waiting for avatar render ${avatarVideoId} (last: ${exportStatus}).`
|
|
2382
|
+
);
|
|
2383
|
+
}
|
|
2384
|
+
await new Promise((r) => setTimeout(r, Math.max(ctx.intervalMs, 5e3)));
|
|
2385
|
+
}
|
|
2386
|
+
} catch (err) {
|
|
2387
|
+
spin.stop();
|
|
2388
|
+
throw err;
|
|
2389
|
+
}
|
|
2390
|
+
});
|
|
2391
|
+
avatar.command("get <avatar_video_id>").description("Fetch one avatar video (status + video_url when rendered)").action(async function(avatarVideoId) {
|
|
2392
|
+
const ctx = buildContext(this);
|
|
2393
|
+
const result = await ctx.client.callTool("get_avatar_video", { avatar_video_id: avatarVideoId });
|
|
2394
|
+
emit(ctx.out, result);
|
|
2395
|
+
});
|
|
2396
|
+
avatar.command("list").description("List your avatar videos").action(async function() {
|
|
2397
|
+
const ctx = buildContext(this);
|
|
2398
|
+
const result = await ctx.client.callTool("list_avatar_videos");
|
|
2399
|
+
const rows = result?.avatar_videos ?? result?.videos ?? result ?? [];
|
|
2400
|
+
emit(ctx.out, result, (o) => {
|
|
2401
|
+
if (!Array.isArray(rows)) return;
|
|
2402
|
+
table(
|
|
2403
|
+
o,
|
|
2404
|
+
["id", "name", "status"],
|
|
2405
|
+
rows.map((v) => [
|
|
2406
|
+
String(v.id ?? v.avatar_video_id ?? ""),
|
|
2407
|
+
String(v.character_name ?? v.name ?? "").slice(0, 30),
|
|
2408
|
+
String(v.export_status ?? v.status ?? "")
|
|
2409
|
+
])
|
|
2410
|
+
);
|
|
2411
|
+
});
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
// src/commands/skills.ts
|
|
2416
|
+
import fs7 from "fs";
|
|
2417
|
+
import path4 from "path";
|
|
2418
|
+
import os2 from "os";
|
|
2419
|
+
import { fileURLToPath } from "url";
|
|
2420
|
+
function bundledSkillDir() {
|
|
2421
|
+
const here = path4.dirname(fileURLToPath(import.meta.url));
|
|
2422
|
+
const candidates = [
|
|
2423
|
+
path4.resolve(here, "../skills/videodraft"),
|
|
2424
|
+
// dist/ or src/commands compiled flat
|
|
2425
|
+
path4.resolve(here, "../../skills/videodraft")
|
|
2426
|
+
// src/commands in dev (tsx)
|
|
2427
|
+
];
|
|
2428
|
+
for (const candidate of candidates) {
|
|
2429
|
+
if (fs7.existsSync(path4.join(candidate, "SKILL.md"))) return candidate;
|
|
2430
|
+
}
|
|
2431
|
+
throw new CliError("Bundled skill not found (package is missing skills/videodraft).");
|
|
2432
|
+
}
|
|
2433
|
+
function copyDir(src, dest) {
|
|
2434
|
+
fs7.mkdirSync(dest, { recursive: true });
|
|
2435
|
+
for (const entry of fs7.readdirSync(src, { withFileTypes: true })) {
|
|
2436
|
+
const from = path4.join(src, entry.name);
|
|
2437
|
+
const to = path4.join(dest, entry.name);
|
|
2438
|
+
if (entry.isDirectory()) copyDir(from, to);
|
|
2439
|
+
else fs7.copyFileSync(from, to);
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
var KNOWN_AGENTS = ["claude", "codex", "cursor"];
|
|
2443
|
+
var AGENT_ALIASES = {
|
|
2444
|
+
claude: "claude",
|
|
2445
|
+
"claude-code": "claude",
|
|
2446
|
+
codex: "codex",
|
|
2447
|
+
"openai-codex": "codex",
|
|
2448
|
+
cursor: "cursor"
|
|
2449
|
+
};
|
|
2450
|
+
function agentDir(agent) {
|
|
2451
|
+
return { claude: ".claude", codex: ".codex", cursor: ".cursor" }[agent];
|
|
2452
|
+
}
|
|
2453
|
+
function targetDir(agent, project) {
|
|
2454
|
+
const root = project ? process.cwd() : os2.homedir();
|
|
2455
|
+
return path4.join(root, agentDir(agent), "skills", "videodraft");
|
|
2456
|
+
}
|
|
2457
|
+
function detectInstalledAgents() {
|
|
2458
|
+
return KNOWN_AGENTS.filter((a) => {
|
|
2459
|
+
try {
|
|
2460
|
+
return fs7.existsSync(path4.join(os2.homedir(), agentDir(a)));
|
|
2461
|
+
} catch {
|
|
2462
|
+
return false;
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
2466
|
+
function resolveAgents(rawValues, all) {
|
|
2467
|
+
if (all) return { agents: [...KNOWN_AGENTS], source: "all" };
|
|
2468
|
+
const requested = rawValues.flatMap((v) => v.split(",")).map((v) => v.trim().toLowerCase()).filter(Boolean);
|
|
2469
|
+
if (requested.length > 0) {
|
|
2470
|
+
const bad = requested.filter((a) => !(a in AGENT_ALIASES));
|
|
2471
|
+
if (bad.length > 0) {
|
|
2472
|
+
throw new CliError(
|
|
2473
|
+
`Unknown agent(s): ${bad.join(", ")}. Use ${KNOWN_AGENTS.join(" | ")} (or --all).`,
|
|
2474
|
+
EXIT.USAGE
|
|
2475
|
+
);
|
|
2476
|
+
}
|
|
2477
|
+
return { agents: [...new Set(requested.map((a) => AGENT_ALIASES[a]))], source: "explicit" };
|
|
2478
|
+
}
|
|
2479
|
+
const detected = detectInstalledAgents();
|
|
2480
|
+
if (detected.length > 0) return { agents: detected, source: "detected" };
|
|
2481
|
+
return { agents: ["claude"], source: "default" };
|
|
2482
|
+
}
|
|
2483
|
+
function registerSkillCommands(program) {
|
|
2484
|
+
const skills = program.command("skills").description("The VideoDraft agent skill");
|
|
2485
|
+
skills.command("install").description("Install the VideoDraft skill (auto-detects your installed agents by default)").option(
|
|
2486
|
+
"--agent <agents>",
|
|
2487
|
+
"claude | codex | cursor \u2014 repeatable or comma-separated. Omit to auto-detect installed agents.",
|
|
2488
|
+
(v, prev = []) => [...prev, v],
|
|
2489
|
+
[]
|
|
2490
|
+
).option("--all", "install for every supported agent (claude, codex, cursor)").option("--project", "install into the current project (./.claude/skills) instead of globally").option("--force", "overwrite existing installation(s)").action(async function() {
|
|
2491
|
+
const ctx = buildContext(this);
|
|
2492
|
+
const opts = this.opts();
|
|
2493
|
+
const src = bundledSkillDir();
|
|
2494
|
+
const { agents, source } = resolveAgents(opts.agent, Boolean(opts.all));
|
|
2495
|
+
if (source === "detected") {
|
|
2496
|
+
note(ctx.out, fmt.dim(ctx.out, `Detected installed agent(s): ${agents.join(", ")}`));
|
|
2497
|
+
} else if (source === "default") {
|
|
2498
|
+
note(
|
|
2499
|
+
ctx.out,
|
|
2500
|
+
fmt.dim(ctx.out, "No agents detected \u2014 defaulting to claude. Target others with --agent or --all.")
|
|
2501
|
+
);
|
|
2502
|
+
}
|
|
2503
|
+
const results = agents.map((agent) => {
|
|
2504
|
+
const dest = targetDir(agent, Boolean(opts.project));
|
|
2505
|
+
const existed = fs7.existsSync(dest);
|
|
2506
|
+
if (existed && !opts.force) {
|
|
2507
|
+
return { agent, installed_to: dest, status: "skipped", reason: "already installed (use --force)" };
|
|
2508
|
+
}
|
|
2509
|
+
copyDir(src, dest);
|
|
2510
|
+
return { agent, installed_to: dest, status: existed ? "overwritten" : "installed" };
|
|
2511
|
+
});
|
|
2512
|
+
capture("cli_skills_install", {
|
|
2513
|
+
agents: agents.join(","),
|
|
2514
|
+
count: agents.length,
|
|
2515
|
+
source,
|
|
2516
|
+
project: Boolean(opts.project)
|
|
2517
|
+
});
|
|
2518
|
+
const anyInstalled = results.some((r) => r.status !== "skipped");
|
|
2519
|
+
emit(ctx.out, { ok: true, results }, (o) => {
|
|
2520
|
+
for (const r of results) {
|
|
2521
|
+
if (r.status === "skipped") {
|
|
2522
|
+
note(o, fmt.yellow(o, `\u2022 ${r.agent}: skipped \u2014 ${r.reason}`));
|
|
2523
|
+
} else {
|
|
2524
|
+
note(o, fmt.green(o, `\u2022 ${r.agent}: ${r.status} \u2192 ${r.installed_to}`));
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
if (agents.includes("codex")) {
|
|
2528
|
+
note(o, fmt.dim(o, 'Codex leans on AGENTS.md \u2014 also add "Use the videodraft skill for video/image generation" there.'));
|
|
2529
|
+
}
|
|
2530
|
+
if (!anyInstalled) {
|
|
2531
|
+
note(o, fmt.dim(o, "Nothing changed. Re-run with --force to overwrite existing installs."));
|
|
2532
|
+
}
|
|
2533
|
+
note(o, fmt.dim(o, "Other agents: npx skills add videodraft-ai/cli"));
|
|
2534
|
+
});
|
|
2535
|
+
});
|
|
2536
|
+
skills.command("path").description("Print the bundled skill's location (for manual installs)").action(async function() {
|
|
2537
|
+
const ctx = buildContext(this);
|
|
2538
|
+
const dir = bundledSkillDir();
|
|
2539
|
+
emit(ctx.out, { path: dir }, () => {
|
|
2540
|
+
process.stdout.write(`${dir}
|
|
2541
|
+
`);
|
|
2542
|
+
});
|
|
2543
|
+
});
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
// src/commands/misc.ts
|
|
2547
|
+
import { spawn as spawn3 } from "child_process";
|
|
2548
|
+
var DOCS_URL = "https://videodraft.ai/cli";
|
|
2549
|
+
function registerMiscCommands(program) {
|
|
2550
|
+
const config = program.command("config").description("CLI configuration");
|
|
2551
|
+
config.command("path").description("Print the config file location").action(async function() {
|
|
2552
|
+
const ctx = buildContext(this);
|
|
2553
|
+
emit(ctx.out, { path: configPath() }, () => process.stdout.write(`${configPath()}
|
|
2554
|
+
`));
|
|
2555
|
+
});
|
|
2556
|
+
config.command("get [key]").description("Show config (tokens redacted)").action(async function(key) {
|
|
2557
|
+
const ctx = buildContext(this);
|
|
2558
|
+
const raw = readConfig();
|
|
2559
|
+
const redacted = {
|
|
2560
|
+
...raw,
|
|
2561
|
+
profiles: Object.fromEntries(
|
|
2562
|
+
Object.entries(raw.profiles).map(([name, p]) => [
|
|
2563
|
+
name,
|
|
2564
|
+
{
|
|
2565
|
+
...p,
|
|
2566
|
+
access_token: p.access_token ? `${p.access_token.slice(0, 13)}\u2026` : void 0,
|
|
2567
|
+
refresh_token: p.refresh_token ? "vd_rt_\u2026" : void 0
|
|
2568
|
+
}
|
|
2569
|
+
])
|
|
2570
|
+
)
|
|
2571
|
+
};
|
|
2572
|
+
const value = key ? redacted[key] : redacted;
|
|
2573
|
+
emit(ctx.out, value ?? null);
|
|
2574
|
+
});
|
|
2575
|
+
config.command("set <key> <value>").description("Set a config value (supported keys: telemetry, base_url)").action(async function(key, value) {
|
|
2576
|
+
const ctx = buildContext(this);
|
|
2577
|
+
if (key === "telemetry") {
|
|
2578
|
+
setTelemetryPreference(!["0", "false", "off"].includes(value.toLowerCase()));
|
|
2579
|
+
} else if (key === "base_url") {
|
|
2580
|
+
updateConfig((c) => {
|
|
2581
|
+
const profile = c.profiles[c.active_profile ?? "default"];
|
|
2582
|
+
if (!profile) throw new CliError("No profile yet \u2014 run `videodraft login` first.", EXIT.USAGE);
|
|
2583
|
+
profile.base_url = value.replace(/\/$/, "");
|
|
2584
|
+
});
|
|
2585
|
+
} else {
|
|
2586
|
+
throw new CliError(`Unsupported key "${key}". Supported: telemetry, base_url.`, EXIT.USAGE);
|
|
2587
|
+
}
|
|
2588
|
+
emit(ctx.out, { ok: true, [key]: value }, (o) => note(o, "Saved."));
|
|
2589
|
+
});
|
|
2590
|
+
program.command("completion <shell>").description("Print shell completions (bash | zsh)").action(async function(shell) {
|
|
2591
|
+
const root = this.parent;
|
|
2592
|
+
const names = root.commands.map((c) => c.name()).sort();
|
|
2593
|
+
if (shell === "bash") {
|
|
2594
|
+
process.stdout.write(
|
|
2595
|
+
`_videodraft_completions() {
|
|
2596
|
+
local cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
2597
|
+
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
2598
|
+
COMPREPLY=( $(compgen -W "${names.join(" ")}" -- "$cur") )
|
|
2599
|
+
fi
|
|
2600
|
+
}
|
|
2601
|
+
complete -F _videodraft_completions videodraft
|
|
2602
|
+
`
|
|
2603
|
+
);
|
|
2604
|
+
} else if (shell === "zsh") {
|
|
2605
|
+
process.stdout.write(
|
|
2606
|
+
`#compdef videodraft
|
|
2607
|
+
_videodraft() {
|
|
2608
|
+
local -a commands
|
|
2609
|
+
commands=(${names.map((n) => `"${n}"`).join(" ")})
|
|
2610
|
+
if (( CURRENT == 2 )); then
|
|
2611
|
+
_describe 'command' commands
|
|
2612
|
+
fi
|
|
2613
|
+
}
|
|
2614
|
+
_videodraft
|
|
2615
|
+
`
|
|
2616
|
+
);
|
|
2617
|
+
} else {
|
|
2618
|
+
throw new CliError(`Unsupported shell "${shell}". Use bash or zsh.`, EXIT.USAGE);
|
|
2619
|
+
}
|
|
2620
|
+
});
|
|
2621
|
+
program.command("version").description("Print the CLI version").action(async function() {
|
|
2622
|
+
const { VERSION: VERSION2 } = await Promise.resolve().then(() => (init_version(), version_exports));
|
|
2623
|
+
process.stdout.write(`${VERSION2}
|
|
2624
|
+
`);
|
|
2625
|
+
});
|
|
2626
|
+
program.command("docs").description("Open the CLI documentation").action(async function() {
|
|
2627
|
+
const ctx = buildContext(this);
|
|
2628
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2629
|
+
try {
|
|
2630
|
+
const child = spawn3(cmd, [DOCS_URL], { stdio: "ignore", detached: true, shell: process.platform === "win32" });
|
|
2631
|
+
child.unref();
|
|
2632
|
+
} catch {
|
|
2633
|
+
}
|
|
2634
|
+
emit(ctx.out, { url: DOCS_URL }, () => process.stdout.write(`${DOCS_URL}
|
|
2635
|
+
`));
|
|
2636
|
+
});
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
// src/index.ts
|
|
2640
|
+
async function configureProxy() {
|
|
2641
|
+
if (!process.env.HTTPS_PROXY && !process.env.HTTP_PROXY && !process.env.https_proxy && !process.env.http_proxy) {
|
|
2642
|
+
return;
|
|
2643
|
+
}
|
|
2644
|
+
try {
|
|
2645
|
+
const { EnvHttpProxyAgent, setGlobalDispatcher } = await import("undici");
|
|
2646
|
+
setGlobalDispatcher(new EnvHttpProxyAgent());
|
|
2647
|
+
} catch {
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
function buildProgram() {
|
|
2651
|
+
const program = new Command();
|
|
2652
|
+
program.name("videodraft").description(
|
|
2653
|
+
"Create AI videos, images, voiceovers and music from your terminal.\nAgent-friendly: every command supports --json; exit codes are stable\n(0 ok, 1 error, 2 usage, 3 auth, 4 insufficient credits)."
|
|
2654
|
+
).version(VERSION, "-v, --version", "print the CLI version").option("--json", "machine-readable JSON output").option("--no-color", "disable colored output (NO_COLOR is also respected)").option("--base-url <url>", "VideoDraft server (default https://app.videodraft.ai; env VIDEODRAFT_BASE_URL)").option("--token <vd_mcp_token>", "bearer token for this invocation (env VIDEODRAFT_API_KEY)").option("--profile <name>", "config profile (default: default)").option("--wait-interval <duration>", "poll interval for --wait, e.g. 3s (default)").option("--wait-timeout <duration>", "max wait for --wait, e.g. 10m (default)").showSuggestionAfterError(true).exitOverride();
|
|
2655
|
+
registerAuthCommands(program);
|
|
2656
|
+
registerAccountCommands(program);
|
|
2657
|
+
registerProjectCommands(program);
|
|
2658
|
+
registerPipelineCommands(program);
|
|
2659
|
+
registerGenerateCommands(program);
|
|
2660
|
+
registerJobCommands(program);
|
|
2661
|
+
registerMediaCommands(program);
|
|
2662
|
+
registerAvatarCommands(program);
|
|
2663
|
+
registerToolCommands(program);
|
|
2664
|
+
registerSkillCommands(program);
|
|
2665
|
+
registerMiscCommands(program);
|
|
2666
|
+
return program;
|
|
2667
|
+
}
|
|
2668
|
+
async function main() {
|
|
2669
|
+
await configureProxy();
|
|
2670
|
+
maybePrintFirstRunNotice();
|
|
2671
|
+
const program = buildProgram();
|
|
2672
|
+
const startedAt = Date.now();
|
|
2673
|
+
let commandPath = "help";
|
|
2674
|
+
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
2675
|
+
commandPath = buildCommandPath(actionCommand);
|
|
2676
|
+
});
|
|
2677
|
+
try {
|
|
2678
|
+
await program.parseAsync(process.argv);
|
|
2679
|
+
capture("cli_command", { command: commandPath, ok: true, duration_ms: Date.now() - startedAt });
|
|
2680
|
+
await maybeCheckForUpdate();
|
|
2681
|
+
} catch (err) {
|
|
2682
|
+
if (err instanceof CommanderError) {
|
|
2683
|
+
const code = err.exitCode === 0 ? 0 : EXIT.USAGE;
|
|
2684
|
+
if (code !== 0) {
|
|
2685
|
+
const out = makeOutput({ json: process.argv.includes("--json") });
|
|
2686
|
+
if (out.json) {
|
|
2687
|
+
process.stdout.write(
|
|
2688
|
+
`${JSON.stringify({ error: err.message, exit_code: code, code: err.code }, null, 2)}
|
|
2689
|
+
`
|
|
2690
|
+
);
|
|
2691
|
+
}
|
|
2692
|
+
capture("cli_command", {
|
|
2693
|
+
command: commandPath,
|
|
2694
|
+
ok: false,
|
|
2695
|
+
error_class: "CommanderError",
|
|
2696
|
+
duration_ms: Date.now() - startedAt
|
|
2697
|
+
});
|
|
2698
|
+
}
|
|
2699
|
+
process.exitCode = code;
|
|
2700
|
+
} else if (err instanceof CliError) {
|
|
2701
|
+
const out = makeOutput({ json: process.argv.includes("--json") });
|
|
2702
|
+
if (out.json) {
|
|
2703
|
+
process.stdout.write(
|
|
2704
|
+
`${JSON.stringify({ error: err.message, exit_code: err.exitCode, hint: err.hint }, null, 2)}
|
|
2705
|
+
`
|
|
2706
|
+
);
|
|
2707
|
+
} else {
|
|
2708
|
+
process.stderr.write(`${fmt.red(out, "Error:")} ${err.message}
|
|
2709
|
+
`);
|
|
2710
|
+
if (err.hint) process.stderr.write(`${fmt.dim(out, err.hint)}
|
|
2711
|
+
`);
|
|
2712
|
+
}
|
|
2713
|
+
process.exitCode = err.exitCode;
|
|
2714
|
+
capture("cli_command", {
|
|
2715
|
+
command: commandPath,
|
|
2716
|
+
ok: false,
|
|
2717
|
+
error_class: err.name,
|
|
2718
|
+
duration_ms: Date.now() - startedAt
|
|
2719
|
+
});
|
|
2720
|
+
} else {
|
|
2721
|
+
const out = makeOutput({ json: process.argv.includes("--json") });
|
|
2722
|
+
const message = err?.message ?? String(err);
|
|
2723
|
+
if (out.json) {
|
|
2724
|
+
process.stdout.write(`${JSON.stringify({ error: message, exit_code: EXIT.ERROR }, null, 2)}
|
|
2725
|
+
`);
|
|
2726
|
+
} else {
|
|
2727
|
+
process.stderr.write(`${fmt.red(out, "Error:")} ${message}
|
|
2728
|
+
`);
|
|
2729
|
+
}
|
|
2730
|
+
process.exitCode = EXIT.ERROR;
|
|
2731
|
+
capture("cli_command", {
|
|
2732
|
+
command: commandPath,
|
|
2733
|
+
ok: false,
|
|
2734
|
+
error_class: err?.name ?? "Error",
|
|
2735
|
+
duration_ms: Date.now() - startedAt
|
|
2736
|
+
});
|
|
2737
|
+
}
|
|
2738
|
+
} finally {
|
|
2739
|
+
await shutdown();
|
|
2740
|
+
}
|
|
2741
|
+
}
|
|
2742
|
+
void main();
|