@aicopycoders/exodus 2026.10.2400-beta.1 → 2026.10.2400-beta.2

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.
@@ -37,6 +37,7 @@ Any user can create and own unlimited brands — `npx @aicopycoders/exodus brand
37
37
  - **List accessible brands:** `npx @aicopycoders/exodus brand list`
38
38
  - **Create a new brand:** `npx @aicopycoders/exodus brand create "<name>"` — creates it server-side, switches you in, and sets up its local subfolder
39
39
  - **Switch brand:** `npx @aicopycoders/exodus brand use <slug>` — or just work from inside that brand's subfolder
40
+ - **Business type (what it sells, what the ad promises):** `npx @aicopycoders/exodus brand show`; change with `npx @aicopycoders/exodus brand set --sells <product|service|app|expertise|finance|other> --promise <body|money|neither>`. The video picks its B-roll from these.
40
41
  - **New brand created on the dashboard?** `npx @aicopycoders/exodus@latest init` creates its subfolder and pulls its profile
41
42
  - **Update the CLI + skills:** `npx @aicopycoders/exodus@latest update` — the only command that refreshes `.claude/skills/` and the workspace docs (`doctor` reports health but never rewrites them)
42
43
  - **Brand voice / ICP / offer:** the active brand's `state/brand-profile.md` (inside its subfolder; at this root on older single-brand installs) — read this at session start and after any switch. If it doesn't match the active brand, the exodus-strategist skill will prompt to refresh.
@@ -35,6 +35,7 @@ Don't surface a "list, switch, or inspect?" menu to the user — figure it out f
35
35
  | "Create a new brand" / "Add a brand called X" / "Set up brand Y" | `create "<name>"` (no admin needed; switches you in) |
36
36
  | "Switch to dlc" / "Change to brand X" | `use <slug>` |
37
37
  | "Show me the foundation for brand X" / "What's brand Y configured with?" | `inspect <slug>` |
38
+ | "What kind of business is this brand?" / "Change the business type" / "It sells an app, not a product" | `show`, then `set --sells ... --promise ...` |
38
39
  | "The new brand isn't showing up" | `list` first — if visible, suggest `use`; if not, the dashboard create didn't propagate |
39
40
 
40
41
  ## Common Commands
@@ -79,6 +80,15 @@ npx @aicopycoders/exodus brand inspect <slug>
79
80
 
80
81
  Surfaces foundation completeness, primer status, connected ad account, and writing-mode defaults. Use this when the user asks "is brand X ready to run?" — don't guess.
81
82
 
83
+ ### Business type (the two tags B-roll follows)
84
+
85
+ ```bash
86
+ npx @aicopycoders/exodus brand show
87
+ npx @aicopycoders/exodus brand set --sells product --promise body
88
+ ```
89
+
90
+ Every brand has two tags: what it sells (`product`, `service`, `app`, `expertise`, `finance`, `other`) and what its ads promise (`body`, `money`, `neither`). The video uses them to pick its extra shots (B-roll). `show` prints both in plain words and who set them; when AI picked them with little to go on, it asks you to check. `set` takes either flag alone once both are set; a brand with no tags yet needs both. The same two dropdowns are on the dashboard's Brand info page.
91
+
82
92
  ### Clear the override
83
93
 
84
94
  ```bash
@@ -240,8 +240,8 @@ under it:
240
240
  a gate parked before the stills are drawn has nothing to redraw. When the run
241
241
  has B-roll (`storyboard` ends with a `change the B-roll shots:` line), offer
242
242
  to add a shot on a line with a note, move one, or remove one with `video
243
- broll`. An add or a move takes about a minute and redraws the pictures, so
244
- wait for it to finish before you approve.
243
+ broll`. Each change redraws only the pictures it changes, so wait for it to
244
+ finish before you approve.
245
245
  - final watch: every piece is made; needs `pull`, a cut, and `upload`. Once a
246
246
  cut is uploaded the stop block leads with `approve` instead, and `status
247
247
  --json` says which state it is in (`stop.cutAttached`, plus the same next-step
@@ -609,10 +609,10 @@ changes:
609
609
  `--note`
610
610
  - remove a shot: `exodus video broll remove <runId> --scene <n>`
611
611
 
612
- An add or a move plans the shot on a worker. It takes about a minute, and then
613
- the storyboard pictures are redrawn. A remove saves at once and also redraws the
614
- pictures. While a shot is being planned, `video broll` says so and the server
615
- refuses another change. Run `video broll <runId>` again until it no longer says
612
+ An add or a move plans the shot on a worker, and then the storyboard pictures it
613
+ changes are redrawn. A remove saves at once, and the pictures it changes are
614
+ redrawn too. Every other picture comes back as it was. While a shot is being
615
+ planned, `video broll` says so and the server refuses another change. Run `video broll <runId>` again until it no longer says
616
616
  it is planning, then read the storyboard again before you ask about approving.
617
617
  If it says the last change didn't work, pass its sentence to the user as it
618
618
  came. Do not approve while a shot is still being planned.
@@ -1,3 +1,5 @@
1
+ declare const BROLL_KIT_KINDS: readonly ["screen", "result", "testimonial", "crew", "product", "legal"];
2
+ type BrollKitKind = (typeof BROLL_KIT_KINDS)[number];
1
3
  export declare const helpText: string;
2
4
  interface Brand {
3
5
  id: string;
@@ -20,5 +22,16 @@ export declare function refreshBrandProfileMd(opts?: {
20
22
  pathRel: string;
21
23
  reason?: string;
22
24
  }>;
25
+ export type BrollAddArgs = {
26
+ ok: true;
27
+ file: string;
28
+ kind: BrollKitKind;
29
+ what: string;
30
+ permission: boolean;
31
+ } | {
32
+ ok: false;
33
+ error: string;
34
+ };
35
+ export declare function parseBrollAdd(files: string[], flags: Record<string, string | boolean>): BrollAddArgs;
23
36
  export declare function run(flags: Record<string, string | boolean>): Promise<void>;
24
37
  export {};
@@ -1,12 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { apiGetDashboard, apiPostDashboard } from "../lib/client.js";
4
- import { sniffImageType, BRAND_IMAGE_MAX_BYTES } from "../lib/image-sniff.js";
3
+ import { apiDeleteDashboard, apiGetDashboard, apiPostDashboard } from "../lib/client.js";
4
+ import { sniffImageType, sniffBrollType, BRAND_IMAGE_MAX_BYTES, BROLL_KIT_MAX_BYTES, } from "../lib/image-sniff.js";
5
+ import { probeDurationSec } from "../lib/preflight.js";
5
6
  import { getActiveBrand, setActiveBrand, clearActiveBrand, findWorkspaceRoot, } from "../lib/state.js";
6
7
  import { detectLayout, ensureBrandDir, resolveActiveBrand, brandStateDir, brandDirFor, } from "../lib/layout.js";
7
8
  import { promptYesNo } from "../lib/prompts.js";
8
9
  import { pkgRef } from "../lib/channel.js";
9
10
  const AUTO_END_MARKER = "<!-- exodus:auto-section-end — manual brand notes below survive `brand use` refreshes -->";
11
+ const BROLL_KIT_KINDS = ["screen", "result", "testimonial", "crew", "product", "legal"];
12
+ const BROLL_KIT_NEEDS_PERMISSION = ["result", "testimonial"];
13
+ const BROLL_KINDS_HELP = "screen (app or site screen recording), result (client result), testimonial,\n" +
14
+ " crew (crew or job-site footage), product (in hand or in use), legal";
10
15
  export const helpText = `
11
16
  exodus brand — list, switch, and inspect brands available to your CLI
12
17
 
@@ -20,6 +25,19 @@ Subcommands:
20
25
  exodus brand images add <files...> --kind founder|product|reference
21
26
  Upload brand-identity images (png/jpg/webp, max 15MB each)
22
27
  exodus brand images list Show the active brand's uploaded identity images
28
+ exodus brand broll add <file> --kind <kind> --what "<what it shows>" [--permission]
29
+ Add a clip or picture to the brand's B-roll kit
30
+ (mp4/mov/webm up to 200MB, png/jpg/webp up to 15MB).
31
+ Kinds: ${BROLL_KINDS_HELP}
32
+ --permission (put it after the file) says you may show
33
+ the person; required for result and testimonial
34
+ exodus brand broll list Show the B-roll kit (your logo and product photos count too)
35
+ exodus brand broll remove <id>
36
+ Remove one kit item (ids come from broll list)
37
+ exodus brand show Show the active brand's two business-type tags
38
+ exodus brand set --sells <product|service|app|expertise|finance|other>
39
+ --promise <body|money|neither>
40
+ Change those tags (either flag alone is fine)
23
41
 
24
42
  Any user can create and own unlimited brands (\`brand create\`) — you do NOT
25
43
  need to be an admin, and brands are scoped to you (two users can have the
@@ -256,6 +274,73 @@ function runCurrent() {
256
274
  console.log("(none — falling back to the key's default brand)");
257
275
  }
258
276
  }
277
+ const WHAT_YOU_SELL_LABELS = {
278
+ product: "Products we ship",
279
+ service: "A service our people do in person",
280
+ app: "An app or software",
281
+ expertise: "Coaching, courses or expert help",
282
+ finance: "Insurance, loans or investing",
283
+ other: "Something else",
284
+ };
285
+ const AD_PROMISE_LABELS = {
286
+ body: "A better body, health or looks",
287
+ money: "Money: income, a payout, debt relief or investment returns",
288
+ neither: "Neither",
289
+ };
290
+ const SET_HINT = "Change them with: exodus brand set --sells <product|service|app|expertise|finance|other> --promise <body|money|neither>";
291
+ function printTags(t) {
292
+ console.log(`Brand: ${t.name}`);
293
+ if (!t.whatYouSell || !t.adPromise) {
294
+ console.log("Business type: not set yet. The video will guess from your script.");
295
+ console.log(SET_HINT);
296
+ return;
297
+ }
298
+ console.log(`What you sell: ${WHAT_YOU_SELL_LABELS[t.whatYouSell]} (${t.whatYouSell})`);
299
+ console.log(`What the ad promises: ${AD_PROMISE_LABELS[t.adPromise]} (${t.adPromise})`);
300
+ if (t.setBy === "member") {
301
+ console.log("Set by: a person on your team");
302
+ }
303
+ else {
304
+ console.log("Set by: AI, from your brand info");
305
+ if (t.unsure) {
306
+ console.log("Please check these. The AI had little to go on.");
307
+ }
308
+ }
309
+ console.log(SET_HINT);
310
+ }
311
+ function failResponse(status, data) {
312
+ const body = data;
313
+ fail(body?.hint ?? body?.detail ?? body?.error ?? `HTTP ${status}`);
314
+ }
315
+ async function runShow() {
316
+ const res = await apiGetDashboard("/api/brands/tags");
317
+ if (!res.ok)
318
+ failResponse(res.status, res.data);
319
+ printTags(res.data);
320
+ }
321
+ async function runSet(flags) {
322
+ const body = {};
323
+ if (flags.sells !== undefined) {
324
+ if (typeof flags.sells !== "string" || !Object.hasOwn(WHAT_YOU_SELL_LABELS, flags.sells)) {
325
+ fail(`--sells must be one of: ${Object.keys(WHAT_YOU_SELL_LABELS).join(", ")}`);
326
+ }
327
+ body.whatYouSell = flags.sells;
328
+ }
329
+ if (flags.promise !== undefined) {
330
+ if (typeof flags.promise !== "string" || !Object.hasOwn(AD_PROMISE_LABELS, flags.promise)) {
331
+ fail(`--promise must be one of: ${Object.keys(AD_PROMISE_LABELS).join(", ")}`);
332
+ }
333
+ body.adPromise = flags.promise;
334
+ }
335
+ if (!body.whatYouSell && !body.adPromise) {
336
+ fail(`nothing to change. ${SET_HINT}`);
337
+ }
338
+ const res = await apiPostDashboard("/api/brands/tags", body);
339
+ if (!res.ok)
340
+ failResponse(res.status, res.data);
341
+ console.log("✓ Saved.");
342
+ printTags(res.data);
343
+ }
259
344
  function runClear() {
260
345
  clearActiveBrand();
261
346
  console.log("✓ Cleared local active brand. Falling back to the key's default.");
@@ -375,6 +460,112 @@ async function runImagesList() {
375
460
  }
376
461
  }
377
462
  }
463
+ export function parseBrollAdd(files, flags) {
464
+ if (typeof flags["permission"] === "string") {
465
+ return { ok: false, error: "--permission takes no value; put it after the file" };
466
+ }
467
+ if (files.length !== 1) {
468
+ return { ok: false, error: "brand broll add takes exactly one file" };
469
+ }
470
+ const kind = flags["kind"];
471
+ if (typeof kind !== "string" || !BROLL_KIT_KINDS.includes(kind)) {
472
+ return {
473
+ ok: false,
474
+ error: `brand broll add requires --kind ${BROLL_KIT_KINDS.join("|")}` +
475
+ (kind === "logo" ? " (the logo is uploaded on the dashboard and is already in the kit)" : ""),
476
+ };
477
+ }
478
+ const what = typeof flags["what"] === "string" ? flags["what"].trim() : "";
479
+ if (!what) {
480
+ return { ok: false, error: 'brand broll add requires --what "<what it shows>"' };
481
+ }
482
+ const permission = flags["permission"] === true;
483
+ if (BROLL_KIT_NEEDS_PERMISSION.includes(kind) && !permission) {
484
+ return {
485
+ ok: false,
486
+ error: `--kind ${kind} needs --permission: you must have permission to show this person`,
487
+ };
488
+ }
489
+ return { ok: true, file: files[0], kind: kind, what, permission };
490
+ }
491
+ async function runBrollAdd(files, flags) {
492
+ const parsed = parseBrollAdd(files, flags);
493
+ if (!parsed.ok)
494
+ fail(`${parsed.error}`);
495
+ const { file, kind, what, permission } = parsed;
496
+ let size;
497
+ try {
498
+ size = fs.statSync(file).size;
499
+ }
500
+ catch {
501
+ fail(`can't read ${file} (does the path exist?)`);
502
+ }
503
+ if (size > BROLL_KIT_MAX_BYTES.video) {
504
+ fail(`${path.basename(file)} is too large (${(size / 1024 / 1024).toFixed(1)}MB > 200MB)`);
505
+ }
506
+ const buf = fs.readFileSync(file);
507
+ const sniffed = sniffBrollType(buf);
508
+ if (!sniffed)
509
+ fail(`${path.basename(file)} is not an mp4/mov/webm video or png/jpg/webp picture`);
510
+ if (buf.length > BROLL_KIT_MAX_BYTES[sniffed.media]) {
511
+ fail(`${path.basename(file)} is too large (a ${sniffed.media} can be up to ${BROLL_KIT_MAX_BYTES[sniffed.media] / 1024 / 1024}MB)`);
512
+ }
513
+ const durationSec = sniffed.media === "video" ? probeDurationSec(file) : null;
514
+ const mint = await apiPostDashboard("/api/brands/broll/upload-url", {});
515
+ if (!mint.ok || !mint.data.url || !mint.data.receiptId)
516
+ fail(`${mint.data.error ?? `HTTP ${mint.status} getting an upload URL`}`);
517
+ const upload = await fetch(mint.data.url, {
518
+ method: "POST",
519
+ headers: { "Content-Type": sniffed.mime },
520
+ body: new Uint8Array(buf),
521
+ });
522
+ const uploaded = (await upload.json().catch(() => ({})));
523
+ if (!upload.ok || !uploaded.storageId)
524
+ fail(`storage upload failed (HTTP ${upload.status})`);
525
+ const add = await apiPostDashboard("/api/brands/broll", {
526
+ storageId: uploaded.storageId,
527
+ receiptId: mint.data.receiptId,
528
+ kind,
529
+ description: what,
530
+ ...(permission ? { permission: true } : {}),
531
+ ...(durationSec !== null ? { durationSec } : {}),
532
+ });
533
+ if (!add.ok)
534
+ fail(`${add.data.error ?? `HTTP ${add.status} adding to the kit`}`);
535
+ const length = durationSec !== null ? `, ${durationSec.toFixed(1)}s` : "";
536
+ console.log(`✓ added ${path.basename(file)} as ${kind} (${sniffed.media}${length}) — id ${add.data.id}`);
537
+ }
538
+ async function runBrollList() {
539
+ const res = await apiGetDashboard("/api/brands/broll");
540
+ if (!res.ok)
541
+ fail(`${res.data.error ?? `HTTP ${res.status}`}`);
542
+ const { brand, items, logo, productPhotos } = res.data;
543
+ console.log(`brand: ${brand}`);
544
+ console.log(`B-roll kit: ${items.length} item${items.length === 1 ? "" : "s"}`);
545
+ for (const item of items) {
546
+ const length = item.durationSec !== undefined ? ` ${item.durationSec.toFixed(1)}s` : "";
547
+ console.log(` ${item.id} ${item.kind} (${item.media}${length}) ${item.description}`);
548
+ }
549
+ console.log(`also in the kit: ${logo ? "logo, " : ""}${productPhotos} product photo${productPhotos === 1 ? "" : "s"}`);
550
+ }
551
+ async function runBrollRemove(id) {
552
+ if (!id)
553
+ fail("brand broll remove requires an item id (see brand broll list)");
554
+ const res = await apiDeleteDashboard(`/api/brands/broll?id=${encodeURIComponent(id)}`);
555
+ if (!res.ok)
556
+ fail(`${res.data.error ?? `HTTP ${res.status}`}`);
557
+ console.log(`✓ removed ${id}`);
558
+ }
559
+ async function runBroll(args, flags) {
560
+ const sub = args[0];
561
+ if (sub === "add")
562
+ return runBrollAdd(args.slice(1), flags);
563
+ if (sub === "list" || sub === undefined)
564
+ return runBrollList();
565
+ if (sub === "remove")
566
+ return runBrollRemove(args[1]);
567
+ fail(`broll: unknown subcommand "${sub}" (use add | list | remove)`);
568
+ }
378
569
  function imagesPositionals() {
379
570
  const rest = process.argv.slice(3);
380
571
  const out = [];
@@ -425,6 +616,9 @@ export async function run(flags) {
425
616
  case "images":
426
617
  await runImages(imagesPositionals().slice(1), flags);
427
618
  return;
619
+ case "broll":
620
+ await runBroll(imagesPositionals().slice(1), flags);
621
+ return;
428
622
  case "list":
429
623
  case undefined:
430
624
  await runList();
@@ -444,6 +638,12 @@ export async function run(flags) {
444
638
  case "clear":
445
639
  runClear();
446
640
  return;
641
+ case "show":
642
+ await runShow();
643
+ return;
644
+ case "set":
645
+ await runSet(flags);
646
+ return;
447
647
  default:
448
648
  console.error(`exodus brand: unknown subcommand "${sub}"\n`);
449
649
  console.log(helpText);
@@ -1,11 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { spawnSync } from "node:child_process";
4
3
  import { apiGet, apiPost, getDashboardUrl } from "../lib/client.js";
5
4
  import { displayRunStatus, formatApiError } from "../lib/format.js";
6
5
  import { isWorkerStoppedPark } from "../lib/runStatus.js";
7
6
  import { missingRouteLine } from "../lib/route-support.js";
8
- import { hasBinary } from "../lib/preflight.js";
7
+ import { probeDurationSec } from "../lib/preflight.js";
9
8
  import { ASSET_UPLOAD_POLICY, pauseAheadLine, } from "./workflow.js";
10
9
  import { HELPER_LEDGER_BASE, SET_OPTION_LEDGER_BASE } from "../lib/helperLedgerBase.js";
11
10
  import { customerAnswer, customerRunPage, customerStageIsWorking, customerStageLines, customerStatusLines, customerStoryboardLines, customerVoicesLines, } from "./video-customer.js";
@@ -77,8 +76,8 @@ The whole loop, in order:
77
76
  exodus video broll <runId>
78
77
  On a run started with --broll: the B-roll shots and the script lines.
79
78
  Add a shot on a line, move one, or remove one while the storyboard
80
- waits. An add or a move takes about a minute and redraws the pictures,
81
- so wait for it before you approve.
79
+ waits. Each change redraws only the pictures it changes, and every
80
+ other picture stays as it was. Wait for it before you approve.
82
81
 
83
82
  7. exodus video status <runId>
84
83
  Where the run is and how each scene's clip turned out. If the storyboard
@@ -336,15 +335,6 @@ export function asVideoRun(data) {
336
335
  const run = (data ?? {});
337
336
  return { ...run, nodes: Array.isArray(run.nodes) ? run.nodes : [] };
338
337
  }
339
- function defaultProbeDurationSec(filePath) {
340
- if (!hasBinary("ffprobe"))
341
- return null;
342
- const res = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { encoding: "utf-8" });
343
- if (res.error || res.status !== 0)
344
- return null;
345
- const seconds = Number.parseFloat((res.stdout ?? "").trim());
346
- return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
347
- }
348
338
  export const defaultDeps = {
349
339
  get: (p) => apiGet(p),
350
340
  post: (p, body) => apiPost(p, body),
@@ -382,7 +372,7 @@ export const defaultDeps = {
382
372
  const parsed = (await res.json().catch(() => ({})));
383
373
  return { ok: true, status: res.status, storageId: parsed.storageId };
384
374
  },
385
- probeDurationSec: defaultProbeDurationSec,
375
+ probeDurationSec,
386
376
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
387
377
  now: () => Date.now(),
388
378
  dashboardUrl: getDashboardUrl(),
@@ -2608,11 +2598,9 @@ function brollEditLines(edit, now) {
2608
2598
  if (now - edit.requestedAt >= BROLL_EDIT_STALE_MS) {
2609
2599
  return [`The last B-roll change (${asked}) never finished. You can ask for it again.`];
2610
2600
  }
2611
- return [
2612
- `Planning now: ${asked}. It takes about a minute, then the storyboard pictures are redrawn.`,
2613
- "Wait for it to finish before you change another shot or approve the storyboard.",
2614
- ];
2601
+ return [`Planning now: ${asked}. Then the storyboard pictures it changes are redrawn.`, BROLL_WAIT];
2615
2602
  }
2603
+ const BROLL_WAIT = "Wait for it to finish before you change another shot or approve the storyboard.";
2616
2604
  export async function brollFlow(runId, json, deps) {
2617
2605
  const res = await deps.get(`${STORYBOARD_PATH}?runId=${encodeURIComponent(runId)}`);
2618
2606
  if (!res.ok)
@@ -2672,25 +2660,19 @@ export async function brollEditFlow(runId, edit, json, deps) {
2672
2660
  const res = await deps.post(BROLL_PATH, { runId, ...edit });
2673
2661
  if (!res.ok)
2674
2662
  return errorResult(res, json);
2675
- if (json)
2676
- return { code: 0, lines: [JSON.stringify({ runId, ...res.data })] };
2677
- if (edit.op === "remove") {
2678
- return {
2679
- code: 0,
2680
- lines: [
2681
- `B-roll scene ${edit.sceneIndex} is gone. The storyboard pictures will be redrawn.`,
2682
- `See them: exodus video storyboard ${runId}`,
2683
- ],
2684
- };
2663
+ const said = edit.op === "remove"
2664
+ ? `B-roll scene ${edit.sceneIndex} is gone. The storyboard pictures it changes are being redrawn.`
2665
+ : `${edit.op === "add"
2666
+ ? `Planning a B-roll shot on line ${edit.lineId}.`
2667
+ : `Moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}.`} Then the storyboard pictures it changes are redrawn.`;
2668
+ if (json) {
2669
+ return { code: 0, lines: [JSON.stringify({ runId, ...res.data, message: `${said} ${BROLL_WAIT}` })] };
2685
2670
  }
2686
2671
  return {
2687
2672
  code: 0,
2688
2673
  lines: [
2689
- edit.op === "add"
2690
- ? `Planning a B-roll shot on line ${edit.lineId}.`
2691
- : `Moving B-roll scene ${edit.sceneIndex} to line ${edit.lineId}.`,
2692
- "It takes about a minute. Then the storyboard pictures are redrawn.",
2693
- "Wait for it to finish before you approve the storyboard.",
2674
+ said,
2675
+ BROLL_WAIT,
2694
2676
  `Check on it: exodus video broll ${runId}`,
2695
2677
  `See the pictures: exodus video storyboard ${runId}`,
2696
2678
  ],
@@ -28,3 +28,4 @@ export declare function apiPostDashboard<T>(path: string, body: unknown, opts?:
28
28
  timeoutMs?: number;
29
29
  activeBrandOverride?: string;
30
30
  }): Promise<ApiResponse<T>>;
31
+ export declare function apiDeleteDashboard<T>(path: string): Promise<ApiResponse<T>>;
@@ -160,6 +160,20 @@ export function getDashboardUrl() {
160
160
  apiUrl: getConfig().apiUrl,
161
161
  });
162
162
  }
163
+ async function readDashboardJson(res, path) {
164
+ const text = await res.text();
165
+ try {
166
+ return JSON.parse(text);
167
+ }
168
+ catch {
169
+ const snippet = text.replace(/\s+/g, " ").trim().slice(0, 300);
170
+ return {
171
+ error: `Non-JSON ${res.status} from ${path}: ${snippet}`,
172
+ httpStatus: res.status,
173
+ contentType: res.headers.get("content-type") ?? undefined,
174
+ };
175
+ }
176
+ }
163
177
  export async function apiGetDashboard(path, opts) {
164
178
  const { apiKey } = getConfig();
165
179
  const url = `${getDashboardUrl()}${path}`;
@@ -182,20 +196,7 @@ export async function apiGetDashboard(path, opts) {
182
196
  if (timer)
183
197
  clearTimeout(timer);
184
198
  }
185
- const text = await res.text();
186
- let data;
187
- try {
188
- data = JSON.parse(text);
189
- }
190
- catch {
191
- const snippet = text.replace(/\s+/g, " ").trim().slice(0, 300);
192
- data = {
193
- error: `Non-JSON ${res.status} from ${path}: ${snippet}`,
194
- httpStatus: res.status,
195
- contentType: res.headers.get("content-type") ?? undefined,
196
- };
197
- }
198
- return { ok: res.ok, status: res.status, data: data };
199
+ return { ok: res.ok, status: res.status, data: await readDashboardJson(res, path) };
199
200
  }
200
201
  export async function apiPostDashboard(path, body, opts) {
201
202
  const { apiKey } = getConfig();
@@ -220,18 +221,13 @@ export async function apiPostDashboard(path, body, opts) {
220
221
  if (timer)
221
222
  clearTimeout(timer);
222
223
  }
223
- const text = await res.text();
224
- let data;
225
- try {
226
- data = JSON.parse(text);
227
- }
228
- catch {
229
- const snippet = text.replace(/\s+/g, " ").trim().slice(0, 300);
230
- data = {
231
- error: `Non-JSON ${res.status} from ${path}: ${snippet}`,
232
- httpStatus: res.status,
233
- contentType: res.headers.get("content-type") ?? undefined,
234
- };
235
- }
236
- return { ok: res.ok, status: res.status, data: data };
224
+ return { ok: res.ok, status: res.status, data: await readDashboardJson(res, path) };
225
+ }
226
+ export async function apiDeleteDashboard(path) {
227
+ const { apiKey } = getConfig();
228
+ const res = await fetch(`${getDashboardUrl()}${path}`, {
229
+ method: "DELETE",
230
+ headers: buildHeaders(apiKey),
231
+ });
232
+ return { ok: res.ok, status: res.status, data: await readDashboardJson(res, path) };
237
233
  }
@@ -4,3 +4,12 @@ export interface SniffedImageType {
4
4
  mime: "image/png" | "image/jpeg" | "image/webp";
5
5
  }
6
6
  export declare function sniffImageType(buf: Uint8Array): SniffedImageType | null;
7
+ export interface SniffedBrollType {
8
+ media: "video" | "image";
9
+ mime: "video/mp4" | "video/quicktime" | "video/webm" | SniffedImageType["mime"];
10
+ }
11
+ export declare const BROLL_KIT_MAX_BYTES: {
12
+ readonly video: number;
13
+ readonly image: number;
14
+ };
15
+ export declare function sniffBrollType(buf: Uint8Array): SniffedBrollType | null;
@@ -16,3 +16,15 @@ export function sniffImageType(buf) {
16
16
  }
17
17
  return null;
18
18
  }
19
+ export const BROLL_KIT_MAX_BYTES = { video: 200 * 1024 * 1024, image: BRAND_IMAGE_MAX_BYTES };
20
+ export function sniffBrollType(buf) {
21
+ const ascii = (from, to) => String.fromCharCode(...buf.subarray(from, to));
22
+ if (buf.length >= 12 && ascii(4, 8) === "ftyp") {
23
+ return { media: "video", mime: ascii(8, 12) === "qt " ? "video/quicktime" : "video/mp4" };
24
+ }
25
+ if (buf.length >= 4 && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3) {
26
+ return ascii(0, Math.min(buf.length, 64)).includes("webm") ? { media: "video", mime: "video/webm" } : null;
27
+ }
28
+ const image = sniffImageType(buf);
29
+ return image ? { media: "image", mime: image.mime } : null;
30
+ }
@@ -1,4 +1,5 @@
1
1
  export declare function hasBinary(name: string): boolean;
2
+ export declare function probeDurationSec(filePath: string): number | null;
2
3
  export interface KeySyncResult {
3
4
  pulled: string[];
4
5
  failed: string[];
@@ -9,6 +9,15 @@ export function hasBinary(name) {
9
9
  return false;
10
10
  }
11
11
  }
12
+ export function probeDurationSec(filePath) {
13
+ if (!hasBinary("ffprobe"))
14
+ return null;
15
+ const res = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", filePath], { encoding: "utf-8" });
16
+ if (res.error || res.status !== 0)
17
+ return null;
18
+ const seconds = Number.parseFloat((res.stdout ?? "").trim());
19
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
20
+ }
12
21
  export async function syncKeysToEnv() {
13
22
  let remote;
14
23
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.10.2400-beta.1",
3
+ "version": "2026.10.2400-beta.2",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",