@vivipilot/cli 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.
Files changed (58) hide show
  1. package/README.md +57 -0
  2. package/dist/api.d.ts +86 -0
  3. package/dist/api.d.ts.map +1 -0
  4. package/dist/api.js +77 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/args.d.ts +11 -0
  7. package/dist/args.d.ts.map +1 -0
  8. package/dist/args.js +53 -0
  9. package/dist/args.js.map +1 -0
  10. package/dist/browser.d.ts +31 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +162 -0
  13. package/dist/browser.js.map +1 -0
  14. package/dist/cli.d.ts +12 -0
  15. package/dist/cli.d.ts.map +1 -0
  16. package/dist/cli.js +536 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/config.d.ts +15 -0
  19. package/dist/config.d.ts.map +1 -0
  20. package/dist/config.js +58 -0
  21. package/dist/config.js.map +1 -0
  22. package/dist/errors.d.ts +6 -0
  23. package/dist/errors.d.ts.map +1 -0
  24. package/dist/errors.js +12 -0
  25. package/dist/errors.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +7 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/manifest.d.ts +40 -0
  31. package/dist/manifest.d.ts.map +1 -0
  32. package/dist/manifest.js +90 -0
  33. package/dist/manifest.js.map +1 -0
  34. package/dist/mcp.d.ts +13 -0
  35. package/dist/mcp.d.ts.map +1 -0
  36. package/dist/mcp.js +392 -0
  37. package/dist/mcp.js.map +1 -0
  38. package/dist/render.d.ts +21 -0
  39. package/dist/render.d.ts.map +1 -0
  40. package/dist/render.js +369 -0
  41. package/dist/render.js.map +1 -0
  42. package/package.json +42 -0
  43. package/src/api.ts +163 -0
  44. package/src/args.test.ts +21 -0
  45. package/src/args.ts +64 -0
  46. package/src/browser.test.ts +103 -0
  47. package/src/browser.ts +174 -0
  48. package/src/cli.ts +656 -0
  49. package/src/config.test.ts +30 -0
  50. package/src/config.ts +71 -0
  51. package/src/errors.ts +14 -0
  52. package/src/index.ts +25 -0
  53. package/src/manifest.test.ts +105 -0
  54. package/src/manifest.ts +126 -0
  55. package/src/mcp.test.ts +48 -0
  56. package/src/mcp.ts +438 -0
  57. package/src/render.ts +424 -0
  58. package/tsconfig.json +26 -0
package/src/cli.ts ADDED
@@ -0,0 +1,656 @@
1
+ #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
3
+ import { createInterface } from "node:readline";
4
+ import { mkdir, writeFile } from "node:fs/promises";
5
+ import { dirname } from "node:path";
6
+ import { flagBoolean, flagNumber, flagString, parseArgv, type ParsedArgs } from "./args.js";
7
+ import {
8
+ defaultConfigPath,
9
+ deleteConfig,
10
+ readConfig,
11
+ resolveApiKey,
12
+ resolveApiUrl,
13
+ writeConfig,
14
+ type CliConfig,
15
+ type Env,
16
+ } from "./config.js";
17
+ import { VivipilotApiClient, type EstimateRequest, type ProgressResponse } from "./api.js";
18
+ import { CliError, isCliError } from "./errors.js";
19
+ import { verifyManifestFile } from "./manifest.js";
20
+ import { renderManifest } from "./render.js";
21
+
22
+ type WritableLike = {
23
+ write(chunk: string): void;
24
+ };
25
+
26
+ type CliIo = {
27
+ stdout: WritableLike;
28
+ stderr: WritableLike;
29
+ };
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Spinner / progress display
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
+
37
+ class ProgressDisplay {
38
+ private io: CliIo;
39
+ private frame = 0;
40
+ private timer: ReturnType<typeof setInterval> | null = null;
41
+ private lastMessage = "";
42
+ private lastStage = "";
43
+
44
+ constructor(io: CliIo) {
45
+ this.io = io;
46
+ }
47
+
48
+ start(message: string): void {
49
+ this.lastMessage = message;
50
+ this.io.stderr.write(`\r${SPINNER_FRAMES[0]} ${message}`);
51
+ this.timer = setInterval(() => {
52
+ this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
53
+ this.io.stderr.write(`\r${SPINNER_FRAMES[this.frame]} ${this.lastMessage}`);
54
+ }, 80);
55
+ }
56
+
57
+ update(stage: string, message: string): void {
58
+ if (stage !== this.lastStage) {
59
+ // Complete the previous stage with a checkmark
60
+ if (this.lastStage) {
61
+ this.io.stderr.write(`\r\x1b[32m✓\x1b[0m ${this.lastMessage}\n`);
62
+ }
63
+ this.lastStage = stage;
64
+ }
65
+ this.lastMessage = message;
66
+ }
67
+
68
+ succeed(message: string): void {
69
+ if (this.timer) clearInterval(this.timer);
70
+ this.timer = null;
71
+ this.io.stderr.write(`\r\x1b[32m✓\x1b[0m ${message}\n`);
72
+ }
73
+
74
+ fail(message: string): void {
75
+ if (this.timer) clearInterval(this.timer);
76
+ this.timer = null;
77
+ this.io.stderr.write(`\r\x1b[31m✗\x1b[0m ${message}\n`);
78
+ }
79
+
80
+ stop(): void {
81
+ if (this.timer) clearInterval(this.timer);
82
+ this.timer = null;
83
+ }
84
+ }
85
+
86
+ const HELP = `Vivipilot CLI (paid-only)
87
+
88
+ Usage:
89
+ vivipilot login --api-key <key> [--api-url <url>]
90
+ vivipilot logout
91
+ vivipilot whoami
92
+ vivipilot balance
93
+ vivipilot topup
94
+ vivipilot estimate --prompt "..." [--width 1280 --height 720 --fps 30 --duration 6 --engine auto]
95
+ vivipilot generate --prompt "..." --out scene.vivi.json [--idempotency-key <key>]
96
+ vivipilot chat [--out-dir ./scenes] [--width 1280 --height 720 --fps 30 --duration 6]
97
+ vivipilot verify scene.vivi.json
98
+ vivipilot render scene.vivi.json --out video.mp4
99
+ vivipilot mcp
100
+
101
+ Commands:
102
+ generate Create a motion graphics scene (shows live progress)
103
+ chat Interactive multi-turn session (like the browser editor)
104
+ render Render a signed manifest to video locally
105
+ verify Verify a manifest's signature
106
+ estimate Preview credit cost without generating
107
+ balance Check your paid credit balance
108
+ topup Get a link to buy more credits
109
+
110
+ Environment:
111
+ VIVIPILOT_API_KEY Paid API key for non-interactive use
112
+ VIVIPILOT_API_URL Defaults to https://vivipilot.com
113
+ VIVIPILOT_MANIFEST_PUBLIC_KEYS JSON map of keyId to Ed25519 public key
114
+
115
+ CLI/MCP generation is paid-only: no trial credits and no free generations.
116
+ `;
117
+
118
+ function writeJson(io: CliIo, value: unknown): void {
119
+ io.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
120
+ }
121
+
122
+ function requirePrompt(parsed: ParsedArgs): string {
123
+ const prompt = flagString(parsed, ["prompt", "p"]) ?? parsed.positionals.join(" ");
124
+ if (!prompt.trim()) throw new CliError("Missing prompt. Pass --prompt \"...\".", 2);
125
+ return prompt.trim();
126
+ }
127
+
128
+ function estimateRequestFromArgs(parsed: ParsedArgs): EstimateRequest {
129
+ const width = flagNumber(parsed, ["width", "w"]);
130
+ const height = flagNumber(parsed, ["height", "h"]);
131
+ const fps = flagNumber(parsed, ["fps"]);
132
+ const durationSeconds = flagNumber(parsed, ["duration", "duration-seconds"]);
133
+ const engineFlag = flagString(parsed, ["engine"]);
134
+ const enginePreference = engineFlag === "pixi" || engineFlag === "three" || engineFlag === "auto" ? engineFlag : undefined;
135
+
136
+ return {
137
+ prompt: requirePrompt(parsed),
138
+ ...(width || height || fps ? { canvas: { width, height, fps } } : {}),
139
+ ...(durationSeconds ? { durationSeconds } : {}),
140
+ ...(enginePreference ? { enginePreference } : {}),
141
+ };
142
+ }
143
+
144
+ function estimateRequestFromPrompt(
145
+ prompt: string,
146
+ defaults: { width?: number; height?: number; fps?: number; durationSeconds?: number },
147
+ ): EstimateRequest {
148
+ return {
149
+ prompt,
150
+ ...(defaults.width || defaults.height || defaults.fps
151
+ ? { canvas: { width: defaults.width, height: defaults.height, fps: defaults.fps } }
152
+ : {}),
153
+ ...(defaults.durationSeconds ? { durationSeconds: defaults.durationSeconds } : {}),
154
+ };
155
+ }
156
+
157
+ async function writeOutputFile(path: string, value: unknown): Promise<void> {
158
+ await mkdir(dirname(path), { recursive: true });
159
+ await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
160
+ }
161
+
162
+ function requireManifestPath(parsed: ParsedArgs): string {
163
+ const path = parsed.positionals[0];
164
+ if (!path) throw new CliError("Missing manifest path.", 2);
165
+ return path;
166
+ }
167
+
168
+ async function handleLogin(parsed: ParsedArgs, env: Env, io: CliIo): Promise<void> {
169
+ const configPath = defaultConfigPath(env);
170
+ const currentConfig = await readConfig(configPath);
171
+ const apiKey = flagString(parsed, ["api-key"]) ?? env.VIVIPILOT_API_KEY;
172
+ if (!apiKey) throw new CliError("Missing paid API key. Pass --api-key or set VIVIPILOT_API_KEY.", 2);
173
+
174
+ const nextConfig: CliConfig = {
175
+ ...currentConfig,
176
+ apiKey,
177
+ apiUrl: flagString(parsed, ["api-url"]) ?? env.VIVIPILOT_API_URL ?? currentConfig.apiUrl,
178
+ };
179
+ await writeConfig(nextConfig, configPath);
180
+ writeJson(io, { ok: true, configPath, paidOnly: true });
181
+ }
182
+
183
+ async function handleLogout(env: Env, io: CliIo): Promise<void> {
184
+ const configPath = defaultConfigPath(env);
185
+ await deleteConfig(configPath);
186
+ writeJson(io, { ok: true, configPath });
187
+ }
188
+
189
+ function apiClient(config: CliConfig, env: Env): VivipilotApiClient {
190
+ return new VivipilotApiClient({ config, env });
191
+ }
192
+
193
+ async function handleTopup(config: CliConfig, env: Env, io: CliIo): Promise<void> {
194
+ const apiUrl = resolveApiUrl(config, env);
195
+ writeJson(io, {
196
+ paidOnly: true,
197
+ topupUrl: `${apiUrl}/edit?billing=topup`,
198
+ message: "CLI/MCP generation requires paid top-up credits.",
199
+ });
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Generate with live progress
204
+ // ---------------------------------------------------------------------------
205
+
206
+ const POLL_INTERVAL_MS = 1500;
207
+ const MAX_POLL_MS = 300_000; // 5 minute total timeout
208
+
209
+ async function generateWithProgress(
210
+ client: VivipilotApiClient,
211
+ request: EstimateRequest,
212
+ idempotencyKey: string,
213
+ outPath: string,
214
+ io: CliIo,
215
+ ): Promise<{ generationId: string; manifestPath: string; creditsCharged: number | null }> {
216
+ const progress = new ProgressDisplay(io);
217
+ progress.start("Starting generation...");
218
+
219
+ // Start the async generation
220
+ const startResult = await client.startGenerate(
221
+ { ...request, outputFormat: "manifest" },
222
+ idempotencyKey,
223
+ );
224
+
225
+ const { generationId } = startResult;
226
+ progress.update("start", `Generation ${generationId} started (est. ${startResult.estimatedCredits} credits, engine: ${startResult.engine})`);
227
+
228
+ // If it was an idempotent replay and already completed, try to get the data
229
+ if (startResult.idempotentReplay && startResult.status === "completed") {
230
+ const status = await client.generationProgress(generationId);
231
+ if (status.manifestData) {
232
+ await writeOutputFile(outPath, status.manifestData);
233
+ progress.succeed(`Scene saved to ${outPath} (${status.creditsCharged ?? 0} credits)`);
234
+ return { generationId, manifestPath: outPath, creditsCharged: status.creditsCharged };
235
+ }
236
+ }
237
+
238
+ // Poll for progress
239
+ const startTime = Date.now();
240
+ let lastProgressMessage = "";
241
+
242
+ while (Date.now() - startTime < MAX_POLL_MS) {
243
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
244
+
245
+ let status: ProgressResponse;
246
+ try {
247
+ status = await client.generationProgress(generationId);
248
+ } catch {
249
+ continue; // Transient network error, keep polling
250
+ }
251
+
252
+ // Show progress update
253
+ if (status.progressMessage && status.progressMessage !== lastProgressMessage) {
254
+ lastProgressMessage = status.progressMessage;
255
+ progress.update(
256
+ status.workflowStatus ?? status.status,
257
+ status.progressMessage,
258
+ );
259
+ }
260
+
261
+ // Terminal states
262
+ if (status.status === "completed") {
263
+ if (status.manifestData) {
264
+ await writeOutputFile(outPath, status.manifestData);
265
+ progress.succeed(`Scene saved to ${outPath} (${status.creditsCharged ?? 0} credits)`);
266
+ return { generationId, manifestPath: outPath, creditsCharged: status.creditsCharged };
267
+ }
268
+ progress.succeed("Generation complete.");
269
+ return { generationId, manifestPath: outPath, creditsCharged: status.creditsCharged };
270
+ }
271
+
272
+ if (status.status === "failed") {
273
+ progress.fail(status.error ?? "Generation failed.");
274
+ throw new CliError(status.error ?? "Generation failed.", 1);
275
+ }
276
+
277
+ if (status.status === "refunded") {
278
+ progress.fail("Generation refunded.");
279
+ throw new CliError("Generation was refunded.", 1);
280
+ }
281
+ }
282
+
283
+ progress.fail("Generation timed out after 5 minutes.");
284
+ throw new CliError(`Generation ${generationId} timed out. Check status with: vivipilot status ${generationId}`, 1);
285
+ }
286
+
287
+ async function handleGenerate(parsed: ParsedArgs, config: CliConfig, env: Env, io: CliIo): Promise<void> {
288
+ if (!resolveApiKey(config, env)) {
289
+ throw new CliError("Vivipilot generate is paid-only. Set VIVIPILOT_API_KEY or run `vivipilot login --api-key <key>`.", 2);
290
+ }
291
+ const out = flagString(parsed, ["out", "o"]) ?? "scene.vivi.json";
292
+ const idempotencyKey = flagString(parsed, ["idempotency-key"]) ?? `cli_${randomUUID()}`;
293
+ const request = estimateRequestFromArgs(parsed);
294
+
295
+ const result = await generateWithProgress(
296
+ apiClient(config, env),
297
+ request,
298
+ idempotencyKey,
299
+ out,
300
+ io,
301
+ );
302
+
303
+ writeJson(io, {
304
+ ok: true,
305
+ generationId: result.generationId,
306
+ manifestPath: result.manifestPath,
307
+ creditsCharged: result.creditsCharged,
308
+ paidOnly: true,
309
+ });
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // Interactive chat command
314
+ // ---------------------------------------------------------------------------
315
+
316
+ type ChatSession = {
317
+ scenes: Array<{ generationId: string; manifestPath: string; prompt: string; creditsCharged: number | null }>;
318
+ turnNumber: number;
319
+ };
320
+
321
+ function chatBanner(io: CliIo): void {
322
+ io.stderr.write("\n\x1b[1m\x1b[36m Vivipilot Chat\x1b[0m (paid-only)\n");
323
+ io.stderr.write(" Type a prompt to generate a motion graphics scene.\n");
324
+ io.stderr.write(" Commands: /render <n>, /verify <n>, /list, /balance, /help, /quit\n\n");
325
+ }
326
+
327
+ function chatHelp(io: CliIo): void {
328
+ io.stderr.write(`
329
+ \x1b[1mChat Commands:\x1b[0m
330
+ <prompt> Generate a new scene from your description
331
+ /render <n> Render scene #n to video (e.g. /render 1)
332
+ /verify <n> Verify scene #n manifest signature
333
+ /list List all generated scenes in this session
334
+ /balance Check your credit balance
335
+ /estimate <p> Estimate credits for a prompt
336
+ /help Show this help
337
+ /quit Exit chat
338
+
339
+ \x1b[1mTips:\x1b[0m
340
+ - Each prompt generates a new scene with live progress
341
+ - Scenes are saved as scene_1.vivi.json, scene_2.vivi.json, etc.
342
+ - Use /render to render any scene to video after generation
343
+ - All generations are paid-only and charged to your API key
344
+ \n`);
345
+ }
346
+
347
+ async function handleChat(parsed: ParsedArgs, config: CliConfig, env: Env, io: CliIo): Promise<void> {
348
+ if (!resolveApiKey(config, env)) {
349
+ throw new CliError("Vivipilot chat is paid-only. Set VIVIPILOT_API_KEY or run `vivipilot login --api-key <key>`.", 2);
350
+ }
351
+
352
+ const outDir = flagString(parsed, ["out-dir"]) ?? ".";
353
+ const width = flagNumber(parsed, ["width", "w"]);
354
+ const height = flagNumber(parsed, ["height", "h"]);
355
+ const fps = flagNumber(parsed, ["fps"]);
356
+ const durationSeconds = flagNumber(parsed, ["duration", "duration-seconds"]);
357
+
358
+ const client = apiClient(config, env);
359
+ const session: ChatSession = { scenes: [], turnNumber: 0 };
360
+
361
+ chatBanner(io);
362
+
363
+ // Show balance at start
364
+ try {
365
+ const bal = await client.balance();
366
+ io.stderr.write(` \x1b[33mBalance:\x1b[0m ${bal.paidBalance ?? bal.balance} credits\n\n`);
367
+ } catch {
368
+ io.stderr.write(" \x1b[33mCould not fetch balance.\x1b[0m\n\n");
369
+ }
370
+
371
+ const rl = createInterface({
372
+ input: process.stdin,
373
+ output: process.stderr,
374
+ prompt: "\x1b[36mvivipilot>\x1b[0m ",
375
+ });
376
+
377
+ rl.prompt();
378
+
379
+ for await (const line of rl) {
380
+ const trimmed = line.trim();
381
+ if (!trimmed) {
382
+ rl.prompt();
383
+ continue;
384
+ }
385
+
386
+ // Handle slash commands
387
+ if (trimmed.startsWith("/")) {
388
+ const [cmd, ...rest] = trimmed.split(/\s+/);
389
+ const arg = rest.join(" ");
390
+
391
+ switch (cmd) {
392
+ case "/quit":
393
+ case "/exit":
394
+ case "/q":
395
+ io.stderr.write("\nGoodbye!\n");
396
+ rl.close();
397
+ return;
398
+
399
+ case "/help":
400
+ case "/h":
401
+ chatHelp(io);
402
+ break;
403
+
404
+ case "/list":
405
+ case "/ls": {
406
+ if (session.scenes.length === 0) {
407
+ io.stderr.write(" No scenes generated yet.\n");
408
+ } else {
409
+ io.stderr.write("\n \x1b[1mGenerated Scenes:\x1b[0m\n");
410
+ for (let i = 0; i < session.scenes.length; i++) {
411
+ const s = session.scenes[i];
412
+ io.stderr.write(` ${i + 1}. ${s.manifestPath} (${s.creditsCharged ?? "?"} credits)\n`);
413
+ io.stderr.write(` \x1b[2m${s.prompt.slice(0, 80)}${s.prompt.length > 80 ? "..." : ""}\x1b[0m\n`);
414
+ }
415
+ io.stderr.write("\n");
416
+ }
417
+ break;
418
+ }
419
+
420
+ case "/balance":
421
+ case "/bal": {
422
+ try {
423
+ const bal = await client.balance();
424
+ io.stderr.write(` \x1b[33mBalance:\x1b[0m ${bal.paidBalance ?? bal.balance} credits\n`);
425
+ } catch (e) {
426
+ io.stderr.write(` \x1b[31mError:\x1b[0m ${e instanceof Error ? e.message : String(e)}\n`);
427
+ }
428
+ break;
429
+ }
430
+
431
+ case "/estimate":
432
+ case "/est": {
433
+ if (!arg) {
434
+ io.stderr.write(" Usage: /estimate <prompt>\n");
435
+ break;
436
+ }
437
+ try {
438
+ const est = await client.estimate(estimateRequestFromPrompt(arg, { width, height, fps, durationSeconds }));
439
+ io.stderr.write(` \x1b[33mEstimate:\x1b[0m ${est.estimatedCredits} credits (engine: ${est.engine})\n`);
440
+ } catch (e) {
441
+ io.stderr.write(` \x1b[31mError:\x1b[0m ${e instanceof Error ? e.message : String(e)}\n`);
442
+ }
443
+ break;
444
+ }
445
+
446
+ case "/render": {
447
+ const idx = parseInt(arg, 10) - 1;
448
+ if (isNaN(idx) || idx < 0 || idx >= session.scenes.length) {
449
+ io.stderr.write(` Usage: /render <scene number 1-${session.scenes.length}>\n`);
450
+ break;
451
+ }
452
+ const scene = session.scenes[idx];
453
+ const videoOut = scene.manifestPath.replace(/\.vivi\.json$/, ".mp4");
454
+ io.stderr.write(` Rendering ${scene.manifestPath} -> ${videoOut}...\n`);
455
+ try {
456
+ const result = await renderManifest(
457
+ { manifestPath: scene.manifestPath, outPath: videoOut },
458
+ config,
459
+ env,
460
+ );
461
+ io.stderr.write(` \x1b[32m✓\x1b[0m Rendered to ${result.outPath} (${result.size} bytes)\n`);
462
+ } catch (e) {
463
+ io.stderr.write(` \x1b[31m✗\x1b[0m Render failed: ${e instanceof Error ? e.message : String(e)}\n`);
464
+ }
465
+ break;
466
+ }
467
+
468
+ case "/verify": {
469
+ const vidx = parseInt(arg, 10) - 1;
470
+ if (isNaN(vidx) || vidx < 0 || vidx >= session.scenes.length) {
471
+ io.stderr.write(` Usage: /verify <scene number 1-${session.scenes.length}>\n`);
472
+ break;
473
+ }
474
+ const sc = session.scenes[vidx];
475
+ try {
476
+ const vResult = await verifyManifestFile(sc.manifestPath, config, env);
477
+ if (vResult.ok) {
478
+ io.stderr.write(` \x1b[32m✓\x1b[0m Manifest verified: ${vResult.manifest.manifestId}\n`);
479
+ } else {
480
+ io.stderr.write(` \x1b[31m✗\x1b[0m Verification failed: ${vResult.message}\n`);
481
+ }
482
+ } catch (e) {
483
+ io.stderr.write(` \x1b[31m✗\x1b[0m ${e instanceof Error ? e.message : String(e)}\n`);
484
+ }
485
+ break;
486
+ }
487
+
488
+ default:
489
+ io.stderr.write(` Unknown command: ${cmd}. Type /help for available commands.\n`);
490
+ }
491
+
492
+ rl.prompt();
493
+ continue;
494
+ }
495
+
496
+ // It's a prompt — generate a scene
497
+ session.turnNumber++;
498
+ const sceneNum = session.turnNumber;
499
+ const outPath = `${outDir}/scene_${sceneNum}.vivi.json`.replace(/^\.\//, "");
500
+ const idempotencyKey = `chat_${randomUUID()}`;
501
+
502
+ io.stderr.write("\n");
503
+
504
+ try {
505
+ const result = await generateWithProgress(
506
+ client,
507
+ estimateRequestFromPrompt(trimmed, { width, height, fps, durationSeconds }),
508
+ idempotencyKey,
509
+ outPath,
510
+ io,
511
+ );
512
+
513
+ session.scenes.push({
514
+ generationId: result.generationId,
515
+ manifestPath: result.manifestPath,
516
+ prompt: trimmed,
517
+ creditsCharged: result.creditsCharged,
518
+ });
519
+
520
+ io.stderr.write(` \x1b[2mScene #${sceneNum} saved. Use /render ${sceneNum} to render, or type another prompt.\x1b[0m\n\n`);
521
+ } catch (e) {
522
+ io.stderr.write(` \x1b[31mGeneration failed:\x1b[0m ${e instanceof Error ? e.message : String(e)}\n\n`);
523
+ }
524
+
525
+ rl.prompt();
526
+ }
527
+
528
+ // Stream ended (stdin closed)
529
+ if (session.scenes.length > 0) {
530
+ io.stderr.write(`\n Session complete. ${session.scenes.length} scene(s) generated.\n`);
531
+ }
532
+ }
533
+
534
+ // ---------------------------------------------------------------------------
535
+ // Other command handlers (unchanged)
536
+ // ---------------------------------------------------------------------------
537
+
538
+ async function handleVerify(parsed: ParsedArgs, config: CliConfig, env: Env, io: CliIo): Promise<void> {
539
+ const manifestPath = requireManifestPath(parsed);
540
+ const result = await verifyManifestFile(manifestPath, config, env);
541
+ if (!result.ok) throw new CliError(`Manifest verification failed: ${result.message}`, 1);
542
+ writeJson(io, {
543
+ ok: true,
544
+ manifestId: result.manifest.manifestId,
545
+ generationId: result.manifest.generationId,
546
+ canonicalPayloadHash: result.canonicalPayloadHash,
547
+ });
548
+ }
549
+
550
+ async function handleRender(parsed: ParsedArgs, config: CliConfig, env: Env, io: CliIo): Promise<void> {
551
+ const manifestPath = requireManifestPath(parsed);
552
+ const out = flagString(parsed, ["out", "o"]) ?? "video.mp4";
553
+ const format = flagString(parsed, ["format"]) as "mp4" | "webm" | "gif" | "mov" | undefined;
554
+ const scale = flagNumber(parsed, ["scale"]);
555
+ const fpsFlag = flagNumber(parsed, ["fps"]);
556
+ const transparent = flagBoolean(parsed, ["transparent"]);
557
+ const verifyOnly = flagBoolean(parsed, ["verify-only"]);
558
+
559
+ const result = await renderManifest(
560
+ {
561
+ manifestPath,
562
+ outPath: out,
563
+ ...(format ? { format } : {}),
564
+ ...(scale ? { scale } : {}),
565
+ ...(fpsFlag ? { fps: fpsFlag } : {}),
566
+ ...(transparent ? { transparent } : {}),
567
+ verifyOnly,
568
+ },
569
+ config,
570
+ env,
571
+ );
572
+
573
+ writeJson(io, {
574
+ ok: true,
575
+ manifestId: result.manifestId,
576
+ out: result.outPath,
577
+ size: result.size,
578
+ paidOnly: true,
579
+ });
580
+ }
581
+
582
+ async function handleMcp(config: CliConfig, env: Env, io: CliIo): Promise<void> {
583
+ const { startMcpServer } = await import("./mcp.js");
584
+ await startMcpServer({
585
+ config,
586
+ env,
587
+ stdin: process.stdin,
588
+ stdout: io.stdout as NodeJS.WritableStream,
589
+ stderr: io.stderr as NodeJS.WritableStream,
590
+ });
591
+ }
592
+
593
+ export async function runCli(argv = process.argv.slice(2), env: Env = process.env, io: CliIo = { stdout: process.stdout, stderr: process.stderr }): Promise<void> {
594
+ const parsed = parseArgv(argv);
595
+ if (!parsed.command || parsed.command === "help" || flagBoolean(parsed, ["help"])) {
596
+ io.stdout.write(HELP);
597
+ return;
598
+ }
599
+
600
+ const configPath = defaultConfigPath(env);
601
+ const config = await readConfig(configPath);
602
+
603
+ switch (parsed.command) {
604
+ case "login":
605
+ await handleLogin(parsed, env, io);
606
+ return;
607
+ case "logout":
608
+ await handleLogout(env, io);
609
+ return;
610
+ case "whoami":
611
+ writeJson(io, await apiClient(config, env).whoami());
612
+ return;
613
+ case "balance":
614
+ writeJson(io, await apiClient(config, env).balance());
615
+ return;
616
+ case "topup":
617
+ await handleTopup(config, env, io);
618
+ return;
619
+ case "estimate":
620
+ writeJson(io, await apiClient(config, env).estimate(estimateRequestFromArgs(parsed)));
621
+ return;
622
+ case "generate":
623
+ await handleGenerate(parsed, config, env, io);
624
+ return;
625
+ case "chat":
626
+ await handleChat(parsed, config, env, io);
627
+ return;
628
+ case "status": {
629
+ const generationId = parsed.positionals[0];
630
+ if (!generationId) throw new CliError("Missing generation id.", 2);
631
+ writeJson(io, await apiClient(config, env).generationStatus(generationId));
632
+ return;
633
+ }
634
+ case "verify":
635
+ await handleVerify(parsed, config, env, io);
636
+ return;
637
+ case "render":
638
+ await handleRender(parsed, config, env, io);
639
+ return;
640
+ case "mcp":
641
+ await handleMcp(config, env, io);
642
+ return;
643
+ default:
644
+ throw new CliError(`Unknown command: ${parsed.command}\n\n${HELP}`, 2);
645
+ }
646
+ }
647
+
648
+ runCli().catch((error: unknown) => {
649
+ if (isCliError(error)) {
650
+ process.stderr.write(`${error.message}\n`);
651
+ process.exitCode = error.exitCode;
652
+ return;
653
+ }
654
+ process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
655
+ process.exitCode = 1;
656
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { defaultConfigPath, resolveApiKey, resolveApiUrl, resolvePublicKeys, type CliConfig, type Env } from "./config.js";
3
+
4
+ describe("CLI config", () => {
5
+ it("resolves config path from VIVIPILOT_CONFIG", () => {
6
+ expect(defaultConfigPath({ VIVIPILOT_CONFIG: "/tmp/vivi.json" })).toBe("/tmp/vivi.json");
7
+ });
8
+
9
+ it("prefers env API settings over config", () => {
10
+ const config: CliConfig = { apiUrl: "https://config.example", apiKey: "config-key" };
11
+ const env: Env = { VIVIPILOT_API_URL: "https://env.example/", VIVIPILOT_API_KEY: " env-key " };
12
+
13
+ expect(resolveApiUrl(config, env)).toBe("https://env.example");
14
+ expect(resolveApiKey(config, env)).toBe("env-key");
15
+ });
16
+
17
+ it("loads public keys from single-key env pair", () => {
18
+ expect(resolvePublicKeys({}, {
19
+ VIVIPILOT_MANIFEST_PUBLIC_KEY_ID: "key_1",
20
+ VIVIPILOT_MANIFEST_PUBLIC_KEY: "public",
21
+ })).toEqual({ key_1: "public" });
22
+ });
23
+
24
+ it("loads public keys from JSON env", () => {
25
+ expect(resolvePublicKeys({}, {
26
+ VIVIPILOT_MANIFEST_PUBLIC_KEYS: JSON.stringify({ key_1: "public" }),
27
+ })).toEqual({ key_1: "public" });
28
+ });
29
+ });
30
+