@aicopycoders/exodus 2026.9.1900-beta.0 → 2026.9.1900-beta.1

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.
@@ -263,6 +263,7 @@ Then, on either route:
263
263
  exodus video status <runId> [--json] the park and each scene's clip/voice/picture
264
264
  exodus video pull <runId> --out <dir> [--json] every piece + manifest.json
265
265
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "<t>"] [--json] redo one clip at the final watch
266
+ exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json] redo only the voice on finished clips; no new video
266
267
  exodus video upload <runId> --file <cut.mp4> [--duration <s>] [--json] attach the cut (MP4/MOV/WebM, 200 MB)
267
268
  Cut: your choice of tool — Exodus does not stitch or mix. One option ships with the skill:
268
269
  node .claude/skills/exodus-video/scripts/first-cut.mjs <dir> [--skip n,m] [--no-music] [--out f]
@@ -100,6 +100,7 @@ exodus video status <runId> where it is, per scene
100
100
  exodus video pull <runId> --out ./ad-<runId> every piece + manifest.json
101
101
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "…"]
102
102
  redo one clip at the final watch
103
+ exodus video revoice <runId> --all | --scene <n> redo only the voice; no new video
103
104
  … make the cut (your choice of tool)
104
105
  exodus video upload <runId> --file ./ad-<runId>/cut.mp4 attach it
105
106
  exodus video approve <runId> make it the ad
@@ -176,6 +177,7 @@ Verified against the routes, not assumed. Everything here is admin-gated on top.
176
177
  | script-turn edits at the gate | **refused** | dashboard only |
177
178
  | `video retry-frame` | works | works |
178
179
  | `video retry-clip` | works | works |
180
+ | `video revoice` | works | works |
179
181
  | `video pull` | works | works |
180
182
  | `video upload` | only if the video node sets `finalWatch: true` | works |
181
183
  | `workflow checkpoint approve` | **refused at a video gate** | **refused at a video gate** |
@@ -277,9 +279,11 @@ whole loop unattended.
277
279
 
278
280
  ## Giving the characters voices
279
281
 
280
- By default a workflow run's characters have no voice chosen, so every clip keeps
281
- the voice the video model invents. `exodus video voices` is where that gets
282
- fixed, and it only works **before** the storyboard is approved — once clips start
282
+ By default a workflow run's characters have no voice of their own. A character
283
+ without one gets the run's default voice (the workflow's narrator voice) when the
284
+ run has one; with no default either, the clip keeps the voice the video model
285
+ invents. `exodus video voices` shows which of the three applies and is where it
286
+ gets fixed, and it only works **before** the storyboard is approved — once clips start
283
287
  it refuses.
284
288
 
285
289
  ```
@@ -363,7 +367,10 @@ canChange false once the run has left the storyboard review
363
367
  whyNot why, in one sentence, when canChange is false
364
368
  cast[] one per character
365
369
  characterId, name
366
- voice { voiceId, label } or null
370
+ voice { voiceId, label }: the voice this character's clips are
371
+ converted to. null only when they have no voice of their
372
+ own AND the run has no default voice
373
+ voiceFrom "own-pin" or "run-default"; absent when voice is null
367
374
  availability { state: "available" | "missing" | "not-checked", … }
368
375
  spokenScenes how many scenes this character speaks in
369
376
  spokenSeconds those scenes' planned seconds — what ElevenLabs bills on
@@ -378,8 +385,8 @@ A failure is the same shape every `video` verb uses:
378
385
  `{ "ok": false, "status": 400, "error": "<one plain sentence>" }`, exit 1.
379
386
 
380
387
  **Before you approve, read `notices`.** The ones that cost the user something:
381
- a character who speaks but has no voice (those clips keep their generated
382
- voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
388
+ a character who speaks but has no voice of their own and no run default to fall
389
+ back on (those clips keep their generated voice), no ElevenLabs key saved on the run (no voice can be applied at all), and
383
390
  a voice ElevenLabs no longer has.
384
391
 
385
392
  `estimatedCostUsd` is always null. Exodus cannot price a voice conversion yet;
@@ -445,6 +452,10 @@ scenes[] one per scene, in order
445
452
  speechTrimmed the clip was re-cut to its spoken words
446
453
  (0.5 s before the first, 0.8 s after the
447
454
  last); null when there is no clip
455
+ rawStorageId the stored file the video model made, kept
456
+ on record when the voice pass replaced the
457
+ clip with a new file; null when the clip IS
458
+ that file, or there is no clip
448
459
  failed[] files that did not download: {file, url, error}
449
460
  ```
450
461
 
@@ -610,6 +621,65 @@ and compare the file before and after.
610
621
  the CLI says so when that is the case. Pull again, re-cut and upload again before
611
622
  anyone approves.
612
623
 
624
+ ## Redoing only the voice on finished clips
625
+
626
+ `revoice` puts clips that are already paid for back through the voice pass: the
627
+ spoken words are checked against the script, the clip is trimmed to the speech,
628
+ and the chosen voice is applied. It makes no new video, so the picture cannot
629
+ change and no video credits are spent. ElevenLabs bills the voice work to the
630
+ member's own key.
631
+
632
+ ```
633
+ exodus video revoice <runId> --all
634
+ exodus video revoice <runId> --scene 3
635
+ ```
636
+
637
+ **When to reach for it.** `status` shows talking clips with `voice-unpinned` or
638
+ `voice-not-applied` (usually next to `speech-check-skipped`), and the manifest
639
+ shows `revoiced: false` with no words file. The usual cause is a run that had no ElevenLabs key when its
640
+ clips were made. `voices <runId>` says so: "This run has no ElevenLabs key saved".
641
+
642
+ **It finds the key by itself.** Before queuing anything, the server gives the run
643
+ the ElevenLabs key saved on the run owner's account, if the run has none. When
644
+ there is no saved key either, nothing is queued and the refusal says where to add
645
+ one (Settings → Keys). Approving a storyboard does the same top-up, so an older
646
+ run parked at its storyboard is fixed before any clip is paid for.
647
+
648
+ **`--all` picks the clips that need it.** Every finished clip whose file is still
649
+ the video model's own and whose findings carry `voice-unpinned` or
650
+ `voice-not-applied`, the two codes that mean a voice change should have happened
651
+ and did not. `speech-check-skipped` alone does not qualify: it also lands on
652
+ clips whose sound the video model or a later step owns, where there is no voice
653
+ to change and a pass would bill a transcription for nothing. `--scene <n>` is
654
+ not filtered this way; the server decides. It sends one request per scene, tries
655
+ every one, and reports each. Scenes refused with the same sentence share one
656
+ line (a run with no key prints that sentence once); `--json` keeps one entry per
657
+ scene.
658
+
659
+ **What is refused.** A clip that already went through the voice pass (`revoiced`,
660
+ `speechTrimmed` or a trimmed tail): the pass would trim or convert it twice. Use
661
+ `retry-clip` to make that clip again from scratch. A failed clip, a scene already
662
+ being redone, a step still rendering, and an ad already delivered.
663
+
664
+ **An older server starts nothing.** The voice redo has its own server route and
665
+ its own worker task. A server or worker from before it existed cannot run it, and
666
+ the CLI never falls back to `retry-clip`'s route, which would make new paid video.
667
+ You get one sentence saying the server does not have the voice redo yet, and
668
+ nothing is queued or spent.
669
+
670
+ **A clip is never lost.** The clip stays on its row the whole time. If the voice
671
+ pass cannot finish, the clip, its findings and its cost are left as they were and
672
+ one warning, `revoice-not-finished`, is added. Run it again. If ElevenLabs refuses
673
+ the key, the clip comes back unchanged with `voice-not-applied`.
674
+
675
+ **If the word check now fails.** The voiced clip is still delivered, and the scene
676
+ is flagged with the finding, because the picture is the same one the ad already
677
+ had. Decide from `status`: keep it, or `retry-clip` it.
678
+
679
+ **Afterwards.** Pull again. Each voiced scene now has `revoiced: true`, a words
680
+ file, and `rawStorageId` naming the original file. Re-cut and upload again if a
681
+ cut was already uploaded; the CLI says so when that is the case.
682
+
613
683
  ## Cutting the ad
614
684
 
615
685
  Editing is the part every user does differently, and the team is deliberately
@@ -5,6 +5,7 @@ import { readdirSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { dirname, join } from "node:path";
7
7
  import { getVersion } from "../lib/version.js";
8
+ import { parseArgs } from "../lib/args.js";
8
9
  const OTHER_COMMANDS = new Set([
9
10
  "browse",
10
11
  "status",
@@ -113,42 +114,8 @@ async function buildHelp() {
113
114
  ];
114
115
  return sections.filter((s) => s.trim() !== "").join("\n\n");
115
116
  }
116
- function parseArgs(argv) {
117
- const [, , rawCommand = "help", ...rest] = argv;
118
- const command = ["--help", "-h", "-help"].includes(rawCommand) ? "help" : rawCommand;
119
- const flags = {};
120
- let i = 0;
121
- while (i < rest.length) {
122
- const arg = rest[i];
123
- if (arg === "--help" || arg === "-h") {
124
- flags["help"] = true;
125
- i++;
126
- continue;
127
- }
128
- if (arg.startsWith("--")) {
129
- const key = arg.slice(2);
130
- const next = rest[i + 1];
131
- if (key.startsWith("no-")) {
132
- flags[key.slice(3)] = false;
133
- i++;
134
- }
135
- else if (next !== undefined && !next.startsWith("--")) {
136
- flags[key] = next;
137
- i += 2;
138
- }
139
- else {
140
- flags[key] = true;
141
- i++;
142
- }
143
- }
144
- else {
145
- i++;
146
- }
147
- }
148
- return { command, flags };
149
- }
150
117
  async function main() {
151
- const { command, flags } = parseArgs(process.argv);
118
+ const { command, flags, occurrences } = parseArgs(process.argv);
152
119
  if (["--version", "-v", "-V", "version"].includes(command)) {
153
120
  console.log(getVersion());
154
121
  process.exit(0);
@@ -180,7 +147,7 @@ async function main() {
180
147
  console.error(`Command "${command}" does not export a run() function.`);
181
148
  process.exit(1);
182
149
  }
183
- await commandModule.run(flags);
150
+ await commandModule.run(flags, occurrences);
184
151
  }
185
152
  main().catch((err) => {
186
153
  console.error("Fatal error:", err instanceof Error ? err.message : String(err));
@@ -1,4 +1,5 @@
1
1
  import { type ApiResponse } from "../lib/client.js";
2
+ import type { FlagOccurrence } from "../lib/args.js";
2
3
  export declare const helpText: string;
3
4
  export declare const VOICES_PATH = "/api/v2/video/voices";
4
5
  export interface ClipWord {
@@ -43,6 +44,8 @@ export type ArtifactSubset = {
43
44
  final?: boolean;
44
45
  revoiced?: boolean;
45
46
  speechTrimmed?: boolean;
47
+ tailTrimmed?: boolean;
48
+ rawStorageId?: string;
46
49
  } | {
47
50
  type: "audio";
48
51
  sceneIndex?: number;
@@ -202,6 +205,7 @@ export interface ManifestScene {
202
205
  qc: ClipQc | null;
203
206
  revoiced: boolean | null;
204
207
  speechTrimmed: boolean | null;
208
+ rawStorageId: string | null;
205
209
  clipStatus: string;
206
210
  error: string | null;
207
211
  flagged: boolean;
@@ -288,6 +292,11 @@ export interface ClipRedoTarget {
288
292
  }
289
293
  export declare function planClipRedo(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
290
294
  export declare function retryClipFlow(runId: string, target: ClipRedoTarget, note: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
295
+ export declare function planClipRevoice(run: VideoRun, items: NodeItem[], target: ClipRedoTarget): ClipRedoPlan;
296
+ export type RevoiceTarget = ClipRedoTarget | {
297
+ all: true;
298
+ };
299
+ export declare function revoiceFlow(runId: string, target: RevoiceTarget, json: boolean, deps: VideoDeps): Promise<FlowResult>;
291
300
  export interface CastVoiceRow {
292
301
  characterId: string;
293
302
  name: string;
@@ -295,6 +304,7 @@ export interface CastVoiceRow {
295
304
  voiceId: string;
296
305
  label?: string;
297
306
  } | null;
307
+ voiceFrom?: "own-pin" | "run-default";
298
308
  availability?: {
299
309
  state: "available";
300
310
  providerName: string;
@@ -334,7 +344,7 @@ export type VoiceMap = Record<string, string | {
334
344
  voiceId: string;
335
345
  label?: string;
336
346
  } | null>;
337
- export declare function parseVoiceFlags(argv: readonly string[], readFile: (path: string) => string): VoiceMap | null;
347
+ export declare function parseVoiceFlags(occurrences: FlagOccurrence[], readFile: (path: string) => string): VoiceMap | null;
338
348
  export declare function voicesFlow(runId: string, voices: VoiceMap | null, json: boolean, deps: VideoDeps): Promise<FlowResult>;
339
349
  export declare function voiceSheetLines(sheet: CastVoiceSheet): string[];
340
350
  export declare function pullFlow(runId: string, dir: string, json: boolean, deps: VideoDeps): Promise<FlowResult>;
@@ -343,4 +353,4 @@ export declare const NO_DURATION_MESSAGE: string;
343
353
  export declare function describeFetchFailure(err: unknown): string;
344
354
  export declare function uploadFlow(runId: string, filePath: string, durationFlag: string | undefined, json: boolean, deps: VideoDeps): Promise<FlowResult>;
345
355
  export declare function parsePositional(args?: string[]): string[];
346
- export declare function run(flags: Record<string, string | boolean>): Promise<void>;
356
+ export declare function run(flags: Record<string, string | boolean>, occurrences: FlagOccurrence[]): Promise<void>;
@@ -44,6 +44,12 @@ The whole loop, in order:
44
44
  Redo ONE finished clip while the run waits for the cut. Everything
45
45
  else stays. A --note steers the motion, never the words.
46
46
 
47
+ exodus video revoice <runId> --all
48
+ Redo only the VOICE on clips that kept the video model's own voice
49
+ (for example, the run had no ElevenLabs key when they were made). The
50
+ picture stays, no new video is made, and a clip whose voice pass
51
+ cannot finish is kept as it was. Use --scene <n> for one clip.
52
+
47
53
  8. Make your cut from those files.
48
54
 
49
55
  9. exodus video upload <runId> --file cut.mp4
@@ -61,6 +67,7 @@ Usage:
61
67
  exodus video flag <runId> --note "<what is wrong>" [--json]
62
68
  exodus video retry-frame <runId> --node <nodeId> --scene <n> [--note "..."] [--json]
63
69
  exodus video retry-clip <runId> --scene <n> [--node <nodeId>] [--note "..."] [--json]
70
+ exodus video revoice <runId> (--scene <n> | --all) [--node <nodeId>] [--json]
64
71
  exodus video voices <runId> [--set <character>=<voiceId>] [--clear <character>] [--from <file.json>] [--json]
65
72
  exodus video pull <runId> --out <dir> [--json]
66
73
  exodus video upload <runId> --file <cut.mp4> [--duration <sec>] [--json]
@@ -80,9 +87,11 @@ Options:
80
87
  steer one redo (retry-frame, retry-clip)
81
88
  --node <nodeId> Which scene-frames node holds the still (retry-frame).
82
89
  Which video step holds the clip, when a scene has one on
83
- more than one step (retry-clip)
90
+ more than one step (retry-clip, revoice)
84
91
  --scene <n> Which scene to redo. One scene only, a whole number
85
- (retry-frame, retry-clip)
92
+ (retry-frame, retry-clip, revoice)
93
+ --all Every finished clip that kept the video model's voice
94
+ (revoice)
86
95
  --set <who>=<id> Give a character an ElevenLabs voice (voices). Name the
87
96
  character by its ID or by the name your script uses.
88
97
  Repeat it once per character
@@ -102,6 +111,7 @@ Examples:
102
111
  exodus video approve run_123
103
112
  exodus video retry-frame run_123 --node frames-1 --scene 2
104
113
  exodus video retry-clip run_123 --scene 3 --note "keep the handshake in frame"
114
+ exodus video revoice run_123 --all
105
115
  exodus video voices run_123
106
116
  exodus video voices run_123 --set C1=abc123voiceid --set "HOST 2=def456voiceid"
107
117
  exodus video voices run_123 --from voices.json
@@ -116,6 +126,9 @@ const STORYBOARD_PATH = "/api/v2/video/storyboard";
116
126
  const FLAG_PATH = "/api/v2/video/storyboard/flag";
117
127
  const APPROVE_PATH = "/api/v2/workflow/approve";
118
128
  const SCENE_RETRY_PATH = "/api/v2/workflow/scene/retry";
129
+ const SCENE_REVOICE_PATH = "/api/v2/workflow/scene/revoice";
130
+ const REVOICE_NOT_ON_THIS_SERVER = "This Exodus server does not have the voice redo yet, so nothing was started and nothing " +
131
+ "was spent. It arrives with the next server update.";
119
132
  const FINAL_PATH = "/api/v2/video/final";
120
133
  export const VOICES_PATH = "/api/v2/video/voices";
121
134
  const ASSET_UPLOAD_URL_PATH = "/api/v2/workflows/asset-upload-url";
@@ -359,6 +372,7 @@ function clipFromArtifact(sceneIndex, artifact) {
359
372
  words: artifact.words ?? null,
360
373
  revoiced: artifact.revoiced === true,
361
374
  speechTrimmed: artifact.speechTrimmed === true,
375
+ rawStorageId: artifact.rawStorageId ?? null,
362
376
  };
363
377
  }
364
378
  function keyframeDownload(sceneIndex, imageUrl) {
@@ -653,6 +667,7 @@ export function planPull(run, items, opts) {
653
667
  qc: clip?.qc ?? null,
654
668
  revoiced: clip ? clip.revoiced : null,
655
669
  speechTrimmed: clip ? clip.speechTrimmed : null,
670
+ rawStorageId: clip ? clip.rawStorageId : null,
656
671
  clipStatus: item?.status ?? "missing",
657
672
  error: item?.error ?? null,
658
673
  flagged: item?.flagged === true,
@@ -710,6 +725,7 @@ export function markPullFailure(manifest, failure) {
710
725
  scene.clip = null;
711
726
  scene.revoiced = null;
712
727
  scene.speechTrimmed = null;
728
+ scene.rawStorageId = null;
713
729
  }
714
730
  if (scene.voice === failure.file)
715
731
  scene.voice = null;
@@ -1146,17 +1162,45 @@ const UPLOADED_CUT_WARNING = "The cut you already uploaded will not include the
1146
1162
  "re-cut, and upload again.";
1147
1163
  export function planClipRedo(run, items, target) {
1148
1164
  const stop = classifyRun(run);
1165
+ const found = findClipRow(run, items, target);
1166
+ if ("reason" in found)
1167
+ return { ok: false, reason: found.reason };
1168
+ const { row, uploadedCut } = found;
1169
+ const redoable = row.status === "failed" ||
1170
+ (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1171
+ if (!redoable) {
1172
+ if (row.status === "done") {
1173
+ return {
1174
+ ok: false,
1175
+ reason: `Scene ${target.sceneIndex}'s clip is finished and nothing flagged it. A finished clip ` +
1176
+ "can only be redone while the run is waiting for the final cut.",
1177
+ };
1178
+ }
1179
+ const word = ITEM_STATUS_WORD[row.status] ?? row.status;
1180
+ return {
1181
+ ok: false,
1182
+ reason: `Scene ${target.sceneIndex}'s clip is ${word}, so there is nothing to redo yet.`,
1183
+ };
1184
+ }
1185
+ return {
1186
+ ok: true,
1187
+ nodeId: row.nodeId,
1188
+ sceneIndex: target.sceneIndex,
1189
+ attempt: row.attempt ?? 0,
1190
+ warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
1191
+ };
1192
+ }
1193
+ function findClipRow(run, items, target) {
1149
1194
  const uploadedCut = items.some((i) => i.itemKind === "final" && i.status === "done");
1150
1195
  const rows = items.filter((i) => i.itemKind === "clip" &&
1151
1196
  i.sceneIndex === target.sceneIndex &&
1152
1197
  (target.nodeId === undefined || i.nodeId === target.nodeId));
1153
1198
  if (rows.length === 0) {
1154
- return { ok: false, reason: `Scene ${target.sceneIndex} has no clip to redo.` };
1199
+ return { reason: `Scene ${target.sceneIndex} has no clip to redo.` };
1155
1200
  }
1156
1201
  const nodeIds = [...new Set(rows.map((r) => r.nodeId))];
1157
1202
  if (nodeIds.length > 1) {
1158
1203
  return {
1159
- ok: false,
1160
1204
  reason: `Scene ${target.sceneIndex} has a clip on more than one step (${nodeIds.join(", ")}), ` +
1161
1205
  "so say which one: --node <nodeId>.",
1162
1206
  };
@@ -1165,44 +1209,20 @@ export function planClipRedo(run, items, target) {
1165
1209
  const node = run.nodes.find((n) => n.nodeId === row.nodeId);
1166
1210
  if (node?.status === "running") {
1167
1211
  return {
1168
- ok: false,
1169
1212
  reason: `The "${row.nodeId}" step is still making other scenes. Try again once it has finished ` +
1170
1213
  `(exodus video status ${run._id}).`,
1171
1214
  };
1172
1215
  }
1173
1216
  if (node && node.status !== "done") {
1174
1217
  return {
1175
- ok: false,
1176
1218
  reason: `The "${row.nodeId}" step did not finish (it is "${node.status}"), ` +
1177
1219
  "and one clip can only be redone on a step that finished.",
1178
1220
  };
1179
1221
  }
1180
1222
  if (row.status === "running") {
1181
- return { ok: false, reason: `Scene ${target.sceneIndex} is already being redone.` };
1223
+ return { reason: `Scene ${target.sceneIndex} is already being redone.` };
1182
1224
  }
1183
- const redoable = row.status === "failed" ||
1184
- (row.status === "done" && (row.flagged === true || stop.at === "final-watch"));
1185
- if (!redoable) {
1186
- if (row.status === "done") {
1187
- return {
1188
- ok: false,
1189
- reason: `Scene ${target.sceneIndex}'s clip is finished and nothing flagged it. A finished clip ` +
1190
- "can only be redone while the run is waiting for the final cut.",
1191
- };
1192
- }
1193
- const word = ITEM_STATUS_WORD[row.status] ?? row.status;
1194
- return {
1195
- ok: false,
1196
- reason: `Scene ${target.sceneIndex}'s clip is ${word}, so there is nothing to redo yet.`,
1197
- };
1198
- }
1199
- return {
1200
- ok: true,
1201
- nodeId: row.nodeId,
1202
- sceneIndex: target.sceneIndex,
1203
- attempt: row.attempt ?? 0,
1204
- warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
1205
- };
1225
+ return { row, uploadedCut };
1206
1226
  }
1207
1227
  export async function retryClipFlow(runId, target, note, json, deps) {
1208
1228
  const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
@@ -1263,24 +1283,169 @@ export async function retryClipFlow(runId, target, note, json, deps) {
1263
1283
  ],
1264
1284
  };
1265
1285
  }
1266
- export function parseVoiceFlags(argv, readFile) {
1286
+ function clipIsStillRaw(item) {
1287
+ const a = item.artifact;
1288
+ return (item.status === "done" &&
1289
+ a?.type === "video" &&
1290
+ a.revoiced !== true &&
1291
+ a.speechTrimmed !== true &&
1292
+ a.tailTrimmed !== true);
1293
+ }
1294
+ const VOICE_NOT_CHANGED_CODES = new Set(["voice-unpinned", "voice-not-applied"]);
1295
+ export function planClipRevoice(run, items, target) {
1296
+ const found = findClipRow(run, items, target);
1297
+ if ("reason" in found)
1298
+ return { ok: false, reason: found.reason };
1299
+ const { row, uploadedCut } = found;
1300
+ const remake = `To make the clip again from scratch: exodus video retry-clip ${run._id} --scene ${target.sceneIndex}`;
1301
+ if (row.status !== "done" || row.artifact?.type !== "video") {
1302
+ return {
1303
+ ok: false,
1304
+ reason: `Scene ${target.sceneIndex} has no finished clip, so there is no voice to redo. ${remake}`,
1305
+ };
1306
+ }
1307
+ if (!clipIsStillRaw(row)) {
1308
+ return {
1309
+ ok: false,
1310
+ reason: `Scene ${target.sceneIndex}'s clip has already been through the voice pass, ` +
1311
+ `so it cannot go through it again. ${remake}`,
1312
+ };
1313
+ }
1314
+ return {
1315
+ ok: true,
1316
+ nodeId: row.nodeId,
1317
+ sceneIndex: target.sceneIndex,
1318
+ attempt: row.attempt ?? 0,
1319
+ warnings: uploadedCut ? [UPLOADED_CUT_WARNING] : [],
1320
+ };
1321
+ }
1322
+ function sceneListWords(sceneIndexes) {
1323
+ if (sceneIndexes.length === 1)
1324
+ return `Scene ${sceneIndexes[0]}`;
1325
+ return `Scenes ${sceneIndexes.slice(0, -1).join(", ")} and ${sceneIndexes[sceneIndexes.length - 1]}`;
1326
+ }
1327
+ export async function revoiceFlow(runId, target, json, deps) {
1328
+ const runRes = await deps.get(`${RUN_PATH}?runId=${encodeURIComponent(runId)}`);
1329
+ if (!runRes.ok)
1330
+ return errorResult(runRes, json);
1331
+ const run = asVideoRun(runRes.data);
1332
+ const itemsRes = await deps.get(`${ITEMS_PATH}?runId=${encodeURIComponent(runId)}`);
1333
+ if (!itemsRes.ok)
1334
+ return errorResult(itemsRes, json);
1335
+ const items = itemsRes.data.items ?? [];
1336
+ const targets = "all" in target
1337
+ ? items
1338
+ .filter((i) => i.itemKind === "clip" &&
1339
+ clipIsStillRaw(i) &&
1340
+ (i.findings ?? []).some((f) => VOICE_NOT_CHANGED_CODES.has(f.code)))
1341
+ .sort((a, b) => a.sceneIndex - b.sceneIndex)
1342
+ .map((i) => ({ sceneIndex: i.sceneIndex, nodeId: i.nodeId }))
1343
+ : [target];
1344
+ if (targets.length === 0) {
1345
+ const nothing = "No clip on this run is waiting for a voice pass, so nothing was started.";
1346
+ return {
1347
+ code: 0,
1348
+ lines: json ? [JSON.stringify({ ok: true, runId, scenes: [], note: nothing })] : [nothing],
1349
+ };
1350
+ }
1351
+ const scenes = [];
1352
+ const warnings = new Set();
1353
+ for (const one of targets) {
1354
+ const plan = planClipRevoice(run, items, one);
1355
+ if (!plan.ok) {
1356
+ scenes.push({ sceneIndex: one.sceneIndex, nodeId: one.nodeId, ok: false, error: plan.reason });
1357
+ continue;
1358
+ }
1359
+ const res = await deps.post(SCENE_REVOICE_PATH, {
1360
+ runId,
1361
+ nodeId: plan.nodeId,
1362
+ sceneIndex: plan.sceneIndex,
1363
+ });
1364
+ if (missingRouteLine(res, "exodus video revoice")) {
1365
+ return {
1366
+ code: 1,
1367
+ lines: json
1368
+ ? [JSON.stringify({ ok: false, status: 404, error: REVOICE_NOT_ON_THIS_SERVER })]
1369
+ : [REVOICE_NOT_ON_THIS_SERVER],
1370
+ };
1371
+ }
1372
+ if (res.ok) {
1373
+ plan.warnings.forEach((w) => warnings.add(w));
1374
+ scenes.push({
1375
+ sceneIndex: plan.sceneIndex,
1376
+ nodeId: plan.nodeId,
1377
+ ok: true,
1378
+ triggerRunId: res.data.triggerRunId,
1379
+ });
1380
+ continue;
1381
+ }
1382
+ scenes.push({
1383
+ sceneIndex: plan.sceneIndex,
1384
+ nodeId: plan.nodeId,
1385
+ ok: false,
1386
+ error: videoApiError(res),
1387
+ });
1388
+ }
1389
+ const queued = scenes.filter((r) => r.ok).length;
1390
+ const code = scenes.every((r) => r.ok) ? 0 : 1;
1391
+ const review = reviewUrl(deps.dashboardUrl, run);
1392
+ if (json) {
1393
+ return {
1394
+ code,
1395
+ lines: [
1396
+ JSON.stringify({
1397
+ ok: code === 0,
1398
+ runId,
1399
+ scenes,
1400
+ reviewUrl: review,
1401
+ warnings: [...warnings],
1402
+ }),
1403
+ ],
1404
+ };
1405
+ }
1406
+ const refusedWith = new Map();
1407
+ for (const r of scenes) {
1408
+ if (!r.ok)
1409
+ refusedWith.set(r.error, [...(refusedWith.get(r.error) ?? []), r.sceneIndex]);
1410
+ }
1411
+ const sceneLines = scenes.flatMap((r) => {
1412
+ if (r.ok) {
1413
+ return [
1414
+ `Scene ${r.sceneIndex}: redoing the voice on ${r.nodeId}. triggerRunId: ${r.triggerRunId ?? "-"}`,
1415
+ ];
1416
+ }
1417
+ const sharing = refusedWith.get(r.error) ?? [];
1418
+ if (sharing[0] !== r.sceneIndex)
1419
+ return [];
1420
+ return [`${sceneListWords(sharing)}: not started. ${r.error}`];
1421
+ });
1422
+ if (queued === 0)
1423
+ return { code, lines: sceneLines };
1424
+ return {
1425
+ code,
1426
+ lines: [
1427
+ ...sceneLines,
1428
+ "The picture in each clip stays exactly as it is. No new video is made.",
1429
+ "The words are checked against the script, the clip is trimmed to the speech, and the chosen voice is applied. ElevenLabs bills that to your own key.",
1430
+ "If the voice pass cannot finish on a clip, that clip is kept as it was.",
1431
+ `Watch them land: exodus video status ${runId}`,
1432
+ `Review the run: ${review}`,
1433
+ ...warnings,
1434
+ ],
1435
+ };
1436
+ }
1437
+ export function parseVoiceFlags(occurrences, readFile) {
1267
1438
  const fromFile = {};
1268
1439
  const typed = {};
1269
1440
  let asked = false;
1270
- const valueFor = (i, flag) => {
1271
- const arg = argv[i];
1272
- if (arg.startsWith(`${flag}=`))
1273
- return [arg.slice(flag.length + 1), i];
1274
- const next = argv[i + 1];
1275
- if (next === undefined)
1276
- throw new Error(`${flag} needs a value.`);
1277
- return [next, i + 1];
1441
+ const valueFor = (occurrence) => {
1442
+ if (occurrence.value === undefined)
1443
+ throw new Error(`--${occurrence.flag} needs a value.`);
1444
+ return occurrence.value;
1278
1445
  };
1279
- for (let i = 0; i < argv.length; i++) {
1280
- const arg = argv[i];
1281
- if (arg === "--set" || arg.startsWith("--set=")) {
1282
- const [raw, next] = valueFor(i, "--set");
1283
- i = next;
1446
+ for (const occurrence of occurrences) {
1447
+ if (occurrence.flag === "set") {
1448
+ const raw = valueFor(occurrence);
1284
1449
  const eq = raw.lastIndexOf("=");
1285
1450
  if (eq <= 0 || eq === raw.length - 1) {
1286
1451
  throw new Error("--set must look like <character>=<voice id>, for example " +
@@ -1289,17 +1454,15 @@ export function parseVoiceFlags(argv, readFile) {
1289
1454
  typed[raw.slice(0, eq).trim()] = raw.slice(eq + 1).trim();
1290
1455
  asked = true;
1291
1456
  }
1292
- else if (arg === "--clear" || arg.startsWith("--clear=")) {
1293
- const [raw, next] = valueFor(i, "--clear");
1294
- i = next;
1457
+ else if (occurrence.flag === "clear") {
1458
+ const raw = valueFor(occurrence);
1295
1459
  if (!raw.trim())
1296
1460
  throw new Error("--clear needs a character, for example --clear C1.");
1297
1461
  typed[raw.trim()] = null;
1298
1462
  asked = true;
1299
1463
  }
1300
- else if (arg === "--from" || arg.startsWith("--from=")) {
1301
- const [file, next] = valueFor(i, "--from");
1302
- i = next;
1464
+ else if (occurrence.flag === "from") {
1465
+ const file = valueFor(occurrence);
1303
1466
  let parsed;
1304
1467
  try {
1305
1468
  parsed = JSON.parse(readFile(file));
@@ -1349,10 +1512,12 @@ function availabilityWords(row) {
1349
1512
  return "not checked";
1350
1513
  }
1351
1514
  }
1352
- function voiceWords(voice) {
1515
+ function voiceWords(row) {
1516
+ const voice = row.voice;
1353
1517
  if (!voice)
1354
1518
  return "—";
1355
- return voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
1519
+ const words = voice.label ? `${voice.label} (${voice.voiceId})` : voice.voiceId;
1520
+ return row.voiceFrom === "run-default" ? `${words}, the run's default voice` : words;
1356
1521
  }
1357
1522
  function voiceSummaryLines(cast, notices) {
1358
1523
  const rows = cast.filter((row) => row.spokenScenes > 0 || row.voice !== null);
@@ -1360,7 +1525,7 @@ function voiceSummaryLines(cast, notices) {
1360
1525
  const lines = rows.length > 0 ? ["Voices"] : [];
1361
1526
  for (const row of rows) {
1362
1527
  lines.push(row.voice
1363
- ? ` ${row.name.padEnd(width)} → ${voiceWords(row.voice)} ${availabilityWords(row)}`
1528
+ ? ` ${row.name.padEnd(width)} → ${voiceWords(row)} ${availabilityWords(row)}`
1364
1529
  : ` ${row.name.padEnd(width)} → no voice chosen (keeps the generated voice)`);
1365
1530
  }
1366
1531
  for (const notice of notices)
@@ -1372,7 +1537,7 @@ export function voiceSheetLines(sheet) {
1372
1537
  if (sheet.cast.length === 0)
1373
1538
  lines.push(" Nobody is in this ad yet.");
1374
1539
  for (const row of sheet.cast) {
1375
- const voice = voiceWords(row.voice);
1540
+ const voice = voiceWords(row);
1376
1541
  lines.push(` ${row.characterId} ${row.name} ${voice} ${availabilityWords(row)} ` +
1377
1542
  `speaks in ${row.spokenScenes} scenes, about ${row.spokenSeconds}s`);
1378
1543
  }
@@ -1738,7 +1903,7 @@ function usage(line) {
1738
1903
  console.error(`Error: ${line}`);
1739
1904
  process.exit(1);
1740
1905
  }
1741
- export async function run(flags) {
1906
+ export async function run(flags, occurrences) {
1742
1907
  const [sub, ...rest] = parsePositional();
1743
1908
  const json = flags["json"] === true;
1744
1909
  if (!sub) {
@@ -1771,6 +1936,7 @@ export async function run(flags) {
1771
1936
  "flag",
1772
1937
  "retry-frame",
1773
1938
  "retry-clip",
1939
+ "revoice",
1774
1940
  "voices",
1775
1941
  "pull",
1776
1942
  "upload",
@@ -1815,10 +1981,24 @@ export async function run(flags) {
1815
1981
  }
1816
1982
  return printResult(await retryClipFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, flagString(flags, "note"), json, defaultDeps));
1817
1983
  }
1984
+ if (sub === "revoice") {
1985
+ const sceneRaw = flagString(flags, "scene");
1986
+ const all = flags["all"] === true;
1987
+ if (all === (sceneRaw !== undefined)) {
1988
+ usage("video revoice needs one of --scene <n> (one clip) or --all (every clip that kept the video model's voice).");
1989
+ }
1990
+ if (all)
1991
+ return printResult(await revoiceFlow(runId, { all: true }, json, defaultDeps));
1992
+ const sceneIndex = Number(sceneRaw);
1993
+ if (!Number.isInteger(sceneIndex)) {
1994
+ usage(`video revoice --scene must be a whole scene number, not "${sceneRaw}".`);
1995
+ }
1996
+ return printResult(await revoiceFlow(runId, { sceneIndex, nodeId: flagString(flags, "node") }, json, defaultDeps));
1997
+ }
1818
1998
  if (sub === "voices") {
1819
1999
  let voices;
1820
2000
  try {
1821
- voices = parseVoiceFlags(process.argv.slice(3), defaultDeps.readFile);
2001
+ voices = parseVoiceFlags(occurrences, defaultDeps.readFile);
1822
2002
  }
1823
2003
  catch (err) {
1824
2004
  usage(err.message);
@@ -1,4 +1,5 @@
1
1
  import { type ApiResponse } from "../lib/client.js";
2
+ import type { FlagOccurrence } from "../lib/args.js";
2
3
  import { type PollOptions, type PollResult } from "../lib/poll.js";
3
4
  import { LEGACY_WORKFLOW_RUN_STATUS_VALUES, type RunStatus } from "../lib/runStatus.js";
4
5
  import { type RunDeliverySummary } from "../lib/runVerdict.js";
@@ -416,8 +417,8 @@ interface RunFlowOptions {
416
417
  onWarningLine?: (line: string) => void;
417
418
  }
418
419
  export declare function formatPauseNotice(pauseReason: WorkflowPauseReason | undefined, runId: string, dashboardUrl: string): string[];
419
- export declare function parseRawInputFlags(args: string[]): Record<string, string>;
420
- export declare function parseInputFlags(args: string[], readFile?: (path: string) => string): Record<string, string>;
420
+ export declare function parseRawInputFlags(occurrences: FlagOccurrence[]): Record<string, string>;
421
+ export declare function parseInputFlags(occurrences: FlagOccurrence[], readFile?: (path: string) => string): Record<string, string>;
421
422
  export declare const ASSET_UPLOAD_POLICY: Record<WorkflowMediaType, {
422
423
  mimeByExtension: Record<string, string>;
423
424
  maxBytes: number;
@@ -426,11 +427,11 @@ export declare const ASSET_UPLOAD_POLICY: Record<WorkflowMediaType, {
426
427
  export declare const NO_DELIVERIES_WARNING: string;
427
428
  export declare function noDeliveriesWarning(described: unknown): string | undefined;
428
429
  export declare function serverWarningsToPrint(serverWarnings: unknown, alreadyPrinted: string[]): string[];
429
- export declare function rejectTerminalFlag(args: string[]): void;
430
+ export declare function rejectTerminalFlag(occurrences: FlagOccurrence[]): void;
430
431
  export declare function parseAutoApproveFlag(args: string[]): boolean;
431
- export declare function parseRigOverridesFlag(args: string[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
432
- export declare function parseVoicesFlag(args: string[], readFile?: (path: string) => string): VoiceMap | undefined;
433
- export declare function parseFillFlag(args: string[]): string | undefined;
432
+ export declare function parseRigOverridesFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): Record<string, unknown> | undefined;
433
+ export declare function parseVoicesFlag(occurrences: FlagOccurrence[], readFile?: (path: string) => string): VoiceMap | undefined;
434
+ export declare function parseFillFlag(occurrences: FlagOccurrence[]): string | undefined;
434
435
  export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
435
436
  export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;
436
437
  export declare function formatWorkflowVersions(versions: WorkflowVersion[]): string;
@@ -586,9 +587,9 @@ export declare function repairFlow(runId: string, action: "retry" | "skip" | "ki
586
587
  json: boolean;
587
588
  onProgressLine?: (line: string) => void;
588
589
  }, deps: WorkflowRunDeps): Promise<FlowResult>;
589
- export declare function parseSlotFlags(args: string[]): Record<string, string>;
590
+ export declare function parseSlotFlags(occurrences: FlagOccurrence[]): Record<string, string>;
590
591
  export declare function answerFlow(runId: string, values: Record<string, string>, opts: {
591
592
  json: boolean;
592
593
  }, deps: WorkflowRunDeps): Promise<FlowResult>;
593
- export declare function run(flags: Record<string, string | boolean>): Promise<void>;
594
+ export declare function run(flags: Record<string, string | boolean>, occurrences: FlagOccurrence[]): Promise<void>;
594
595
  export {};
@@ -552,35 +552,25 @@ function normalizeMultiValue(value) {
552
552
  .filter((p) => p.length > 0)
553
553
  .join(", ");
554
554
  }
555
- export function parseRawInputFlags(args) {
555
+ export function parseRawInputFlags(occurrences) {
556
556
  const inputs = {};
557
- for (let i = 0; i < args.length; i++) {
558
- const arg = args[i];
559
- let raw;
560
- if (arg === "--input") {
561
- raw = args[i + 1];
562
- i++;
563
- }
564
- else if (arg.startsWith("--input=")) {
565
- raw = arg.slice("--input=".length);
566
- }
567
- else {
557
+ for (const { flag, value } of occurrences) {
558
+ if (flag !== "input")
568
559
  continue;
569
- }
570
- if (!raw)
560
+ if (!value)
571
561
  throw new Error("--input requires key=value");
572
- const eq = raw.indexOf("=");
562
+ const eq = value.indexOf("=");
573
563
  if (eq <= 0)
574
- throw new Error(`--input must be key=value (got "${raw}")`);
575
- const key = raw.slice(0, eq).trim();
564
+ throw new Error(`--input must be key=value (got "${value}")`);
565
+ const key = value.slice(0, eq).trim();
576
566
  if (!key)
577
- throw new Error(`--input must include a key (got "${raw}")`);
578
- inputs[key] = raw.slice(eq + 1);
567
+ throw new Error(`--input must include a key (got "${value}")`);
568
+ inputs[key] = value.slice(eq + 1);
579
569
  }
580
570
  return inputs;
581
571
  }
582
- export function parseInputFlags(args, readFile) {
583
- const inputs = parseRawInputFlags(args);
572
+ export function parseInputFlags(occurrences, readFile) {
573
+ const inputs = parseRawInputFlags(occurrences);
584
574
  for (const [key, value] of Object.entries(inputs)) {
585
575
  inputs[key] = expandInputValue(key, value, readFile);
586
576
  }
@@ -876,12 +866,10 @@ async function prepareRunInputs(workflowId, raw, deps, note) {
876
866
  }
877
867
  return { inputs: prepared, warnings };
878
868
  }
879
- export function rejectTerminalFlag(args) {
880
- for (const arg of args) {
881
- if (arg === "--terminal" || arg.startsWith("--terminal=")) {
882
- throw new Error("--terminal is no longer supported: a run now executes the whole " +
883
- "workflow or it doesn't start. Re-run without --terminal.");
884
- }
869
+ export function rejectTerminalFlag(occurrences) {
870
+ if (occurrences.some((o) => o.flag === "terminal")) {
871
+ throw new Error("--terminal is no longer supported: a run now executes the whole " +
872
+ "workflow or it doesn't start. Re-run without --terminal.");
885
873
  }
886
874
  }
887
875
  export function parseAutoApproveFlag(args) {
@@ -894,21 +882,11 @@ export function parseAutoApproveFlag(args) {
894
882
  }
895
883
  return false;
896
884
  }
897
- export function parseRigOverridesFlag(args, readFile) {
885
+ export function parseRigOverridesFlag(occurrences, readFile) {
898
886
  let raw;
899
- for (let i = 0; i < args.length; i++) {
900
- const arg = args[i];
901
- let value;
902
- if (arg === "--rig-overrides") {
903
- value = args[i + 1];
904
- i++;
905
- }
906
- else if (arg.startsWith("--rig-overrides=")) {
907
- value = arg.slice("--rig-overrides=".length);
908
- }
909
- else {
887
+ for (const { flag, value } of occurrences) {
888
+ if (flag !== "rig-overrides")
910
889
  continue;
911
- }
912
890
  if (value === undefined || value.startsWith("--")) {
913
891
  throw new Error("--rig-overrides requires JSON or @path/to/file.json");
914
892
  }
@@ -951,21 +929,11 @@ export function parseRigOverridesFlag(args, readFile) {
951
929
  }
952
930
  return parsed;
953
931
  }
954
- export function parseVoicesFlag(args, readFile) {
932
+ export function parseVoicesFlag(occurrences, readFile) {
955
933
  let raw;
956
- for (let i = 0; i < args.length; i++) {
957
- const arg = args[i];
958
- let value;
959
- if (arg === "--voices") {
960
- value = args[i + 1];
961
- i++;
962
- }
963
- else if (arg.startsWith("--voices=")) {
964
- value = arg.slice("--voices=".length);
965
- }
966
- else {
934
+ for (const { flag, value } of occurrences) {
935
+ if (flag !== "voices")
967
936
  continue;
968
- }
969
937
  if (value === undefined || value.startsWith("--")) {
970
938
  throw new Error("--voices requires JSON or @path/to/voices.json");
971
939
  }
@@ -1008,25 +976,15 @@ export function parseVoicesFlag(args, readFile) {
1008
976
  }
1009
977
  return parsed;
1010
978
  }
1011
- export function parseFillFlag(args) {
979
+ export function parseFillFlag(occurrences) {
1012
980
  let name;
1013
- for (let i = 0; i < args.length; i++) {
1014
- const arg = args[i];
1015
- let raw;
1016
- if (arg === "--fill") {
1017
- raw = args[i + 1];
1018
- i++;
1019
- }
1020
- else if (arg.startsWith("--fill=")) {
1021
- raw = arg.slice("--fill=".length);
1022
- }
1023
- else {
981
+ for (const { flag, value } of occurrences) {
982
+ if (flag !== "fill")
1024
983
  continue;
1025
- }
1026
- if (raw === undefined || raw.startsWith("--")) {
984
+ if (value === undefined || value.startsWith("--")) {
1027
985
  throw new Error("--fill requires a saved fill's name");
1028
986
  }
1029
- const trimmed = raw.trim();
987
+ const trimmed = value.trim();
1030
988
  if (!trimmed)
1031
989
  throw new Error("--fill requires a saved fill's name");
1032
990
  name = trimmed;
@@ -2858,30 +2816,20 @@ async function resumeAndMaybeWait(runId, triggerRunId, headline, opts, deps, lan
2858
2816
  const prefix = opts.onProgressLine ? [] : startLines;
2859
2817
  return { code: waited.code, lines: [...prefix, ...waited.lines] };
2860
2818
  }
2861
- export function parseSlotFlags(args) {
2819
+ export function parseSlotFlags(occurrences) {
2862
2820
  const values = {};
2863
- for (let i = 0; i < args.length; i++) {
2864
- const arg = args[i];
2865
- let raw;
2866
- if (arg === "--slot") {
2867
- raw = args[i + 1];
2868
- i++;
2869
- }
2870
- else if (arg.startsWith("--slot=")) {
2871
- raw = arg.slice("--slot=".length);
2872
- }
2873
- else {
2821
+ for (const { flag, value } of occurrences) {
2822
+ if (flag !== "slot")
2874
2823
  continue;
2875
- }
2876
- if (!raw)
2824
+ if (!value)
2877
2825
  throw new Error("--slot requires key=value");
2878
- const eq = raw.indexOf("=");
2826
+ const eq = value.indexOf("=");
2879
2827
  if (eq <= 0)
2880
- throw new Error(`--slot must be key=value (got "${raw}")`);
2881
- const key = raw.slice(0, eq).trim();
2828
+ throw new Error(`--slot must be key=value (got "${value}")`);
2829
+ const key = value.slice(0, eq).trim();
2882
2830
  if (!key)
2883
- throw new Error(`--slot must include a key (got "${raw}")`);
2884
- values[key] = raw.slice(eq + 1);
2831
+ throw new Error(`--slot must include a key (got "${value}")`);
2832
+ values[key] = value.slice(eq + 1);
2885
2833
  }
2886
2834
  return values;
2887
2835
  }
@@ -2953,7 +2901,7 @@ async function maybeReadStdin(flags) {
2953
2901
  return undefined;
2954
2902
  return raw.endsWith("\n") ? raw.slice(0, -1) : raw;
2955
2903
  }
2956
- export async function run(flags) {
2904
+ export async function run(flags, occurrences) {
2957
2905
  const positional = parsePositional();
2958
2906
  const [sub, ...rest] = positional;
2959
2907
  const json = !!flags["json"];
@@ -3018,12 +2966,12 @@ export async function run(flags) {
3018
2966
  let imageRigOverrides;
3019
2967
  let voices;
3020
2968
  try {
3021
- inputs = parseRawInputFlags(process.argv.slice(3));
3022
- rejectTerminalFlag(process.argv.slice(3));
3023
- fill = parseFillFlag(process.argv.slice(3));
2969
+ inputs = parseRawInputFlags(occurrences);
2970
+ rejectTerminalFlag(occurrences);
2971
+ fill = parseFillFlag(occurrences);
3024
2972
  autoApprove = parseAutoApproveFlag(process.argv.slice(3));
3025
- imageRigOverrides = parseRigOverridesFlag(process.argv.slice(3), defaultDeps.readFile);
3026
- voices = parseVoicesFlag(process.argv.slice(3), defaultDeps.readFile);
2973
+ imageRigOverrides = parseRigOverridesFlag(occurrences, defaultDeps.readFile);
2974
+ voices = parseVoicesFlag(occurrences, defaultDeps.readFile);
3027
2975
  }
3028
2976
  catch (e) {
3029
2977
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -3167,7 +3115,7 @@ export async function run(flags) {
3167
3115
  }
3168
3116
  let values;
3169
3117
  try {
3170
- values = parseSlotFlags(process.argv.slice(3));
3118
+ values = parseSlotFlags(occurrences);
3171
3119
  }
3172
3120
  catch (e) {
3173
3121
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -3205,7 +3153,7 @@ export async function run(flags) {
3205
3153
  }
3206
3154
  let fireOverrides;
3207
3155
  try {
3208
- fireOverrides = parseRigOverridesFlag(process.argv.slice(3), defaultDeps.readFile);
3156
+ fireOverrides = parseRigOverridesFlag(occurrences, defaultDeps.readFile);
3209
3157
  }
3210
3158
  catch (e) {
3211
3159
  console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
@@ -0,0 +1,10 @@
1
+ export interface FlagOccurrence {
2
+ flag: string;
3
+ value: string | undefined;
4
+ }
5
+ export interface ParsedArgs {
6
+ command: string;
7
+ flags: Record<string, string | boolean>;
8
+ occurrences: FlagOccurrence[];
9
+ }
10
+ export declare function parseArgs(argv: string[]): ParsedArgs;
@@ -0,0 +1,39 @@
1
+ export function parseArgs(argv) {
2
+ const [, , rawCommand = "help", ...rest] = argv;
3
+ const command = ["--help", "-h", "-help"].includes(rawCommand) ? "help" : rawCommand;
4
+ const flags = {};
5
+ const occurrences = [];
6
+ let i = 0;
7
+ while (i < rest.length) {
8
+ const arg = rest[i];
9
+ if (arg === "--help" || arg === "-h") {
10
+ flags["help"] = true;
11
+ i++;
12
+ continue;
13
+ }
14
+ if (arg.startsWith("--")) {
15
+ const key = arg.slice(2);
16
+ const next = rest[i + 1];
17
+ const eq = key.indexOf("=");
18
+ occurrences.push(eq === -1
19
+ ? { flag: key, value: next }
20
+ : { flag: key.slice(0, eq), value: key.slice(eq + 1) });
21
+ if (key.startsWith("no-")) {
22
+ flags[key.slice(3)] = false;
23
+ i++;
24
+ }
25
+ else if (next !== undefined && !next.startsWith("--")) {
26
+ flags[key] = next;
27
+ i += 2;
28
+ }
29
+ else {
30
+ flags[key] = true;
31
+ i++;
32
+ }
33
+ }
34
+ else {
35
+ i++;
36
+ }
37
+ }
38
+ return { command, flags, occurrences };
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1900-beta.0",
3
+ "version": "2026.9.1900-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",