@aicopycoders/exodus 2026.9.1400 → 2026.9.1700-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.
@@ -1,3 +1,4 @@
1
+ import { type AdAccountRef } from "../lib/meta-ads.js";
1
2
  export declare const helpText: string;
2
3
  export interface LocalValidation {
3
4
  ok: boolean;
@@ -8,3 +9,52 @@ export interface LocalValidation {
8
9
  export declare function validatePackageLocally(pkg: unknown): LocalValidation;
9
10
  export declare function contentTypeFor(filePath: string): string;
10
11
  export declare function run(flags: Record<string, string | boolean>): Promise<void>;
12
+ export declare const VALUE_FLAGS: Set<string>;
13
+ export declare function parsePositional(args?: string[]): string[];
14
+ export interface WinnerDefinitionView {
15
+ accountId?: string | null;
16
+ campaignRoleMap?: Record<string, string> | null;
17
+ ruleVariant?: string | null;
18
+ otherDefinition?: string | null;
19
+ ignoredCampaignIds?: string[] | null;
20
+ defaults?: {
21
+ window?: string | null;
22
+ resultsFloor?: number | null;
23
+ contributionLine?: number | null;
24
+ } | null;
25
+ lastAppliedAt?: number | null;
26
+ setupCompletedAt?: number | null;
27
+ summary?: WinnerDefinitionSummary | null;
28
+ }
29
+ export interface WinnerDefinitionSummaryGroup {
30
+ resultLabel?: string | null;
31
+ objective?: string | null;
32
+ instanceCount?: number | null;
33
+ creativeCount?: number | null;
34
+ totalResults?: number | null;
35
+ winnerCount?: number | null;
36
+ winnerShare?: number | null;
37
+ videoWinners?: number | null;
38
+ imageWinners?: number | null;
39
+ flatCurve?: boolean | null;
40
+ }
41
+ export interface WinnerDefinitionSummary {
42
+ instanceCount?: number | null;
43
+ creativeCount?: number | null;
44
+ winnerCount?: number | null;
45
+ groups?: WinnerDefinitionSummaryGroup[] | null;
46
+ ignoredCampaignCount?: number | null;
47
+ computedAt?: number | null;
48
+ }
49
+ export interface WinnerDefinitionResponse {
50
+ account?: AdAccountRef | null;
51
+ definition?: WinnerDefinitionView | null;
52
+ accounts?: AdAccountRef[];
53
+ }
54
+ export declare function roleTally(map: Record<string, string> | null | undefined): {
55
+ total: number;
56
+ line: string;
57
+ };
58
+ export declare function sharePercent(value: unknown): string;
59
+ export declare function summaryLines(summary: WinnerDefinitionSummary | null | undefined): string[];
60
+ export declare function formatDefinition(data: WinnerDefinitionResponse): string[];
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { apiGet, apiPost } from "../lib/client.js";
4
4
  import { displayRunStatus, formatError, tickerRunStatus } from "../lib/format.js";
5
5
  import { pollUntilDone } from "../lib/poll.js";
6
+ import { accountLine, accountRequiredLines, count as formatCount, dateOrDash, errorCode, } from "../lib/meta-ads.js";
6
7
  export const helpText = `
7
8
  exodus winners — Import your own brand's winning ads as generative fuel
8
9
 
@@ -16,6 +17,14 @@ Usage:
16
17
  exodus winners import <file.json | -> Push a winner package (- reads stdin)
17
18
  exodus winners status <importId> Re-poll an import later
18
19
  exodus winners list Winners Exodus already holds
20
+ exodus winners definition [--account act_…] What THIS brand means by "a winner"
21
+
22
+ Definition flags:
23
+ --account <id> Which connected ad account, e.g. act_1234567890. Required
24
+ only when the brand has more than one connected — a
25
+ definition read off the wrong account is a wrong answer that
26
+ looks right.
27
+ --json Machine-readable JSON output
19
28
 
20
29
  Import flags:
21
30
  --dry-run Local schema check + server dry-run: reports would-create vs
@@ -25,6 +34,10 @@ Import flags:
25
34
 
26
35
  Notes:
27
36
  • Scopes to your active brand's workspace (exodus brand current).
37
+ • \`definition\` reads the answers a human already gave on the dashboard — it
38
+ never writes, and it never shows a machine guess nobody has confirmed. A
39
+ brand with a Meta integration keeps its definition there, not in a local
40
+ file. Its ads read back with \`exodus ads list\`.
28
41
  • Requires your Scrape Creators API key (Settings → Keys) — the own-page
29
42
  match scrape bills your account.
30
43
  • Re-pushing the same file is safe: no duplicate rows, the verdict snapshot
@@ -38,6 +51,8 @@ Examples:
38
51
  cat winners.json | exodus winners import -
39
52
  exodus winners status k97abc...
40
53
  exodus winners list
54
+ exodus winners definition
55
+ exodus winners definition --account act_1234567890 --json
41
56
  `.trim();
42
57
  export function validatePackageLocally(pkg) {
43
58
  const errors = [];
@@ -116,6 +131,8 @@ export async function run(flags) {
116
131
  return runStatus(rest, flags);
117
132
  if (sub === "list")
118
133
  return runList(flags);
134
+ if (sub === "definition")
135
+ return runDefinition(flags);
119
136
  if (!sub) {
120
137
  console.log(helpText);
121
138
  return;
@@ -124,8 +141,24 @@ export async function run(flags) {
124
141
  console.log(helpText);
125
142
  process.exit(1);
126
143
  }
127
- function parsePositional() {
128
- return process.argv.slice(3).filter((a) => !a.startsWith("--"));
144
+ export const VALUE_FLAGS = new Set(["account"]);
145
+ export function parsePositional(args = process.argv.slice(3)) {
146
+ const out = [];
147
+ let i = 0;
148
+ while (i < args.length) {
149
+ const arg = args[i];
150
+ if (arg.startsWith("--")) {
151
+ const key = arg.slice(2).split("=", 1)[0] ?? "";
152
+ if (!arg.includes("=") && VALUE_FLAGS.has(key))
153
+ i += 2;
154
+ else
155
+ i++;
156
+ continue;
157
+ }
158
+ out.push(arg);
159
+ i++;
160
+ }
161
+ return out;
129
162
  }
130
163
  async function runImport(positional, flags) {
131
164
  const json = !!flags["json"];
@@ -409,3 +442,143 @@ async function runList(flags) {
409
442
  console.log(` ${w.verdictSentence}`);
410
443
  }
411
444
  }
445
+ const RULE_VARIANTS = {
446
+ standard: "standard — the smallest set of creatives that together carry most of a result group's results",
447
+ efficiency: "efficiency — the creatives with the best cost per result in each group",
448
+ "ignore-campaigns": "standard, with some campaigns deliberately left out of the count",
449
+ other: "written out in the brand's own words (below)",
450
+ };
451
+ export function roleTally(map) {
452
+ const entries = map && typeof map === "object" ? Object.values(map) : [];
453
+ const counts = new Map();
454
+ for (const role of entries) {
455
+ if (typeof role !== "string" || !role.trim())
456
+ continue;
457
+ const key = role.trim();
458
+ counts.set(key, (counts.get(key) ?? 0) + 1);
459
+ }
460
+ const order = ["testing", "scaling", "other"];
461
+ const parts = [...counts.entries()]
462
+ .sort((a, b) => {
463
+ const ai = order.indexOf(a[0]);
464
+ const bi = order.indexOf(b[0]);
465
+ return (ai === -1 ? order.length : ai) - (bi === -1 ? order.length : bi);
466
+ })
467
+ .map(([role, n]) => `${n} ${role}`);
468
+ return { total: entries.length, line: parts.join(" · ") || "none confirmed yet" };
469
+ }
470
+ export function sharePercent(value) {
471
+ if (typeof value !== "number" || !Number.isFinite(value))
472
+ return "—";
473
+ const pct = value <= 1 ? value * 100 : value;
474
+ return `${Math.round(pct)}%`;
475
+ }
476
+ export function summaryLines(summary) {
477
+ if (!summary) {
478
+ return ["Last run", " (the rule has not been run over this account yet)"];
479
+ }
480
+ const lines = [`Last run — ${dateOrDash(summary.computedAt)}`];
481
+ lines.push(` ${formatCount(summary.instanceCount)} ad instances → ${formatCount(summary.creativeCount)} distinct creatives · ${formatCount(summary.winnerCount)} winners`);
482
+ const groups = Array.isArray(summary.groups) ? summary.groups : [];
483
+ for (const group of groups) {
484
+ const label = group.resultLabel?.trim() || group.objective?.trim() || "(unlabelled group)";
485
+ lines.push(` ${label}: ${formatCount(group.winnerCount)} of ${formatCount(group.creativeCount)} creatives carry ${sharePercent(group.winnerShare)} of ${formatCount(group.totalResults)} results (${formatCount(group.videoWinners)} video / ${formatCount(group.imageWinners)} image)`);
486
+ if (group.flatCurve) {
487
+ lines.push(" flat curve — results spread evenly, so there is no clean winner set here; treat these as top contributors, not outliers.");
488
+ }
489
+ }
490
+ if (typeof summary.ignoredCampaignCount === "number" && summary.ignoredCampaignCount > 0) {
491
+ lines.push(` ${summary.ignoredCampaignCount} campaign(s) were left out of the count.`);
492
+ }
493
+ return lines;
494
+ }
495
+ export function formatDefinition(data) {
496
+ const accounts = Array.isArray(data.accounts) ? data.accounts : [];
497
+ const account = data.account ?? null;
498
+ const definition = data.definition ?? null;
499
+ if (!account) {
500
+ if (accounts.length === 0) {
501
+ return [
502
+ "No Meta ad account is connected to this brand.",
503
+ "Connect one on the dashboard (Settings → Meta) — then the daily sync fills in the ads and this definition.",
504
+ ];
505
+ }
506
+ const lines = ["Pick which ad account you mean:", ...accounts.map(accountLine)];
507
+ const first = accounts[0]?.accountId;
508
+ if (first) {
509
+ lines.push("");
510
+ lines.push(` exodus winners definition --account ${first}`);
511
+ }
512
+ return lines;
513
+ }
514
+ const label = account.name?.trim()
515
+ ? `${account.name.trim()} (${account.accountId ?? "?"})`
516
+ : String(account.accountId ?? "?");
517
+ if (!definition) {
518
+ return [
519
+ `No winner definition for ${label} yet.`,
520
+ "Someone has to say what a winner means for this account before the rule can run — that happens on the dashboard (Settings → Meta → winner setup).",
521
+ ];
522
+ }
523
+ const lines = [];
524
+ lines.push(`Winner definition — ${label}`);
525
+ lines.push(` setup: ${definition.setupCompletedAt ? `confirmed ${dateOrDash(definition.setupCompletedAt)}` : "not finished yet"}`);
526
+ const variant = definition.ruleVariant?.trim() ?? "";
527
+ lines.push(` rule: ${RULE_VARIANTS[variant] ?? (variant || "—")}`);
528
+ const defaults = definition.defaults ?? {};
529
+ lines.push(` dials: window ${defaults.window ?? "—"} · results floor ${formatCount(defaults.resultsFloor)} · contribution line ${sharePercent(defaults.contributionLine)}`);
530
+ const roles = roleTally(definition.campaignRoleMap);
531
+ lines.push(` campaigns: ${roles.total} with a confirmed role — ${roles.line}`);
532
+ const ignored = Array.isArray(definition.ignoredCampaignIds) ? definition.ignoredCampaignIds : [];
533
+ if (ignored.length > 0) {
534
+ lines.push(` ignored: ${ignored.length} campaign(s) left out of the rule`);
535
+ }
536
+ if (definition.lastAppliedAt) {
537
+ lines.push(` last run: ${dateOrDash(definition.lastAppliedAt)}`);
538
+ }
539
+ if (definition.otherDefinition?.trim()) {
540
+ lines.push("");
541
+ lines.push("In the brand's own words");
542
+ for (const line of definition.otherDefinition.trim().split("\n"))
543
+ lines.push(` ${line}`);
544
+ }
545
+ lines.push("");
546
+ lines.push(...summaryLines(definition.summary));
547
+ if (accounts.length > 1) {
548
+ lines.push("");
549
+ lines.push(`This brand has ${accounts.length} connected accounts — each keeps its own definition (--account).`);
550
+ }
551
+ return lines;
552
+ }
553
+ async function runDefinition(flags) {
554
+ const json = !!flags["json"];
555
+ const accountRaw = flags["account"];
556
+ if (accountRaw !== undefined && (typeof accountRaw !== "string" || !accountRaw.trim())) {
557
+ console.error("Error: --account needs an ad account id, e.g. act_1234567890");
558
+ console.log("Usage: exodus winners definition [--account act_…] [--json]");
559
+ process.exit(1);
560
+ }
561
+ const account = typeof accountRaw === "string" ? accountRaw.trim() : undefined;
562
+ const query = account ? `?account=${encodeURIComponent(account)}` : "";
563
+ const res = await apiGet(`/api/v2/winners/definition${query}`);
564
+ if (!res.ok) {
565
+ if (json) {
566
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
567
+ process.exit(1);
568
+ }
569
+ if (res.status === 400 && errorCode(res.data) === "ACCOUNT_REQUIRED") {
570
+ for (const line of accountRequiredLines(res, (id) => `exodus winners definition --account ${id}`)) {
571
+ console.log(line);
572
+ }
573
+ process.exit(1);
574
+ }
575
+ console.log(formatError(res));
576
+ process.exit(1);
577
+ }
578
+ if (json) {
579
+ console.log(JSON.stringify({ ok: true, ...res.data }));
580
+ return;
581
+ }
582
+ for (const line of formatDefinition(res.data))
583
+ console.log(line);
584
+ }
@@ -9,6 +9,7 @@ import { runVerdict } from "../lib/runVerdict.js";
9
9
  import { workflowToYaml, parseWorkflowText } from "../lib/workflowText.js";
10
10
  import { missingRouteLine } from "../lib/route-support.js";
11
11
  import { getChannel } from "../lib/channel.js";
12
+ import { asVideoRun, classifyRun, stopLines } from "./video.js";
12
13
  export const helpText = `
13
14
  exodus workflow — List, describe, run, inspect, import, and export saved workflows
14
15
 
@@ -90,6 +91,13 @@ Flags:
90
91
  person's verdict. This is a choice you make for ONE
91
92
  launch — it is never a setting on the workflow itself.
92
93
  --wait Poll until the workflow run reaches a terminal status.
94
+ A video workflow's storyboard gate and final watch END
95
+ the wait too (exit 0): the run is parked on a decision
96
+ only you can make, so the command prints the same
97
+ "Parked: …" block and next commands "exodus video
98
+ status" prints. A Checkpoint / repair / slots park
99
+ prints its pause notice once and keeps polling so a
100
+ resolve from another shell carries the run on.
93
101
  (checkpoint retry) Re-runs the step feeding the
94
102
  Checkpoint box and waits until its fresh output is
95
103
  ready and the run parks again — that IS its finish
@@ -1796,30 +1804,36 @@ export async function runFlow(workflowRef, opts, deps) {
1796
1804
  return { code: waited.code, lines: [...lines, ...waited.lines] };
1797
1805
  }
1798
1806
  const WAIT_TERMINAL_STATUSES = TERMINAL_RUN_STATUSES.flatMap((s) => storedWorkflowStatusForms(s));
1807
+ function isParkedSnapshot(raw) {
1808
+ const status = raw["status"];
1809
+ return typeof status === "string" && normalizeRunStatus(status) === "awaiting-approval";
1810
+ }
1811
+ function videoParkStop(raw) {
1812
+ if (!isParkedSnapshot(raw))
1813
+ return undefined;
1814
+ const stop = classifyRun(asVideoRun(raw));
1815
+ return stop.at === "storyboard-gate" || stop.at === "final-watch" ? stop : undefined;
1816
+ }
1799
1817
  async function waitForRun(runId, opts, deps) {
1800
1818
  const seen = new Map();
1801
1819
  let pausedNotified = false;
1802
1820
  const landOnPark = opts.landOnPark;
1821
+ const isLandingPark = (raw) => (landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason) ||
1822
+ videoParkStop(raw) !== undefined;
1803
1823
  const pollResult = await deps.poll({
1804
1824
  path: `${STATUS_PATH}?runId=${encodeURIComponent(runId)}`,
1805
1825
  intervalMs: 3_000,
1806
1826
  timeoutMs: 60 * 60 * 1000,
1807
- terminalStatuses: landOnPark
1808
- ? [...WAIT_TERMINAL_STATUSES, ...storedWorkflowStatusForms("awaiting-approval")]
1809
- : WAIT_TERMINAL_STATUSES,
1810
- ...(landOnPark
1811
- ? {
1812
- isDone: (raw) => !(typeof raw["status"] === "string" &&
1813
- normalizeRunStatus(raw["status"]) === "awaiting-approval") || raw["pauseReason"] === landOnPark.pauseReason,
1814
- }
1815
- : {}),
1827
+ terminalStatuses: [
1828
+ ...WAIT_TERMINAL_STATUSES,
1829
+ ...storedWorkflowStatusForms("awaiting-approval"),
1830
+ ],
1831
+ isDone: (raw) => !isParkedSnapshot(raw) || isLandingPark(raw),
1816
1832
  onProgress: (raw) => {
1817
1833
  if (opts.json || !opts.onProgressLine)
1818
1834
  return;
1819
- const rawStatus = raw["status"];
1820
- const parked = typeof rawStatus === "string" &&
1821
- normalizeRunStatus(rawStatus) === "awaiting-approval";
1822
- const isLanding = landOnPark !== undefined && raw["pauseReason"] === landOnPark.pauseReason;
1835
+ const parked = isParkedSnapshot(raw);
1836
+ const isLanding = isLandingPark(raw);
1823
1837
  if (parked && !pausedNotified && !isLanding) {
1824
1838
  pausedNotified = true;
1825
1839
  const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
@@ -1848,6 +1862,9 @@ async function waitForRun(runId, opts, deps) {
1848
1862
  const saved = opts.out !== undefined && terminalRun
1849
1863
  ? await saveDeliveries(terminalRun, opts.out, deps)
1850
1864
  : undefined;
1865
+ const videoStop = !pollResult.timedOut && isRecord(pollResult.data)
1866
+ ? videoParkStop(pollResult.data)
1867
+ : undefined;
1851
1868
  if (opts.json) {
1852
1869
  return {
1853
1870
  code: pollResult.ok ? 0 : 1,
@@ -1856,6 +1873,7 @@ async function waitForRun(runId, opts, deps) {
1856
1873
  ...opts.jsonBase,
1857
1874
  result: pollResult.data,
1858
1875
  timedOut: pollResult.timedOut,
1876
+ ...(videoStop ? { stop: videoStop } : {}),
1859
1877
  ...(saved ? { saved: saved.paths } : {}),
1860
1878
  }),
1861
1879
  ],
@@ -1884,6 +1902,9 @@ async function waitForRun(runId, opts, deps) {
1884
1902
  const dashboardUrl = deps.dashboardUrl ?? getDashboardUrl();
1885
1903
  lines.push("", landOnPark.headline, ...formatPauseNotice(landOnPark.pauseReason, runId, dashboardUrl).slice(1));
1886
1904
  }
1905
+ if (videoStop) {
1906
+ lines.push("", ...stopLines(videoStop, runId, deps.dashboardUrl ?? getDashboardUrl()));
1907
+ }
1887
1908
  return { code: pollResult.ok ? 0 : 1, lines };
1888
1909
  }
1889
1910
  export async function statusFlow(opts, deps) {
@@ -0,0 +1,108 @@
1
+ import { type ApiResponse } from "./client.js";
2
+ import { type Channel } from "./channel.js";
3
+ export interface AdAccountRef {
4
+ accountId?: string | null;
5
+ name?: string | null;
6
+ currency?: string | null;
7
+ }
8
+ export interface AdMedia {
9
+ image?: string | null;
10
+ video?: string | null;
11
+ poster?: string | null;
12
+ }
13
+ export interface AdWindowStats {
14
+ resultType?: string | null;
15
+ results?: number | null;
16
+ costPerResult?: number | null;
17
+ costPerResultDisplay?: string | null;
18
+ ctr?: number | null;
19
+ ctrDisplay?: string | null;
20
+ cpc?: number | null;
21
+ cpcDisplay?: string | null;
22
+ spend?: number | null;
23
+ spendDisplay?: string | null;
24
+ impressions?: number | null;
25
+ clicks?: number | null;
26
+ currency?: string | null;
27
+ }
28
+ export interface AdSummary extends AdWindowStats {
29
+ adId?: string | null;
30
+ accountId?: string | null;
31
+ name?: string | null;
32
+ status?: string | null;
33
+ format?: string | null;
34
+ campaign?: {
35
+ id?: string | null;
36
+ name?: string | null;
37
+ objective?: string | null;
38
+ } | null;
39
+ adset?: {
40
+ id?: string | null;
41
+ name?: string | null;
42
+ optimizationGoal?: string | null;
43
+ } | null;
44
+ createdTime?: string | number | null;
45
+ headline?: string | null;
46
+ bodyText?: string | null;
47
+ winnerStatus?: string | null;
48
+ media?: AdMedia | null;
49
+ syncedAt?: string | number | null;
50
+ }
51
+ export interface AdsListResponse {
52
+ ads?: AdSummary[];
53
+ count?: number;
54
+ window?: string;
55
+ sort?: string;
56
+ accounts?: AdAccountRef[];
57
+ }
58
+ export interface AdShowResponse extends AdSummary {
59
+ lifetime?: AdWindowStats | null;
60
+ last90d?: AdWindowStats | null;
61
+ commentCounts?: {
62
+ facebook?: number | null;
63
+ instagram?: number | null;
64
+ } | null;
65
+ }
66
+ export interface AdComment {
67
+ id?: string | null;
68
+ adId?: string | null;
69
+ platform?: string | null;
70
+ text?: string | null;
71
+ createdAt?: string | number | null;
72
+ likeCount?: number | null;
73
+ parentId?: string | null;
74
+ syncedAt?: string | number | null;
75
+ }
76
+ export interface CommentsResponse {
77
+ adId?: string | null;
78
+ comments?: AdComment[];
79
+ count?: number;
80
+ }
81
+ export interface FlowResult {
82
+ code: number;
83
+ lines: string[];
84
+ warnings?: string[];
85
+ }
86
+ export interface MetaAdsDeps {
87
+ get: (path: string) => Promise<ApiResponse<unknown>>;
88
+ channel: Channel;
89
+ apiUrl: () => string;
90
+ now: () => number;
91
+ }
92
+ export declare const defaultMetaAdsDeps: MetaAdsDeps;
93
+ export declare function displayOr(display: unknown, value: unknown, format: (n: number) => string): string;
94
+ export declare function money(n: number, currency?: string | null): string;
95
+ export declare function percent(n: number): string;
96
+ export declare function count(n: unknown): string;
97
+ export declare function sanitizeForTerminal(text: unknown): string;
98
+ export declare function safeText(text: unknown): string;
99
+ export declare function safeTextOr(text: unknown, fallback: string): string;
100
+ export declare function truncate(text: unknown, max: number): string;
101
+ export declare function dateOrDash(value: unknown): string;
102
+ export declare function table(headers: string[], rows: string[][]): string;
103
+ export declare function accountLine(account: AdAccountRef): string;
104
+ export declare function mediaUrl(apiBase: string, relative: unknown): string | null;
105
+ export declare function errorCode(data: unknown): string | undefined;
106
+ export declare function accountsOf(data: unknown): AdAccountRef[];
107
+ export declare function accountRequiredLines(res: ApiResponse<unknown>, example: (accountId: string) => string): string[];
108
+ export declare function errorFor(res: ApiResponse<unknown>, verb: string, json: boolean, deps: MetaAdsDeps, example: (accountId: string) => string): FlowResult | undefined;
@@ -0,0 +1,156 @@
1
+ import { apiGet, getApiUrl } from "./client.js";
2
+ import { formatApiError } from "./format.js";
3
+ import { auth401Hint } from "./backend-hint.js";
4
+ import { getChannel } from "./channel.js";
5
+ import { missingRouteLine } from "./route-support.js";
6
+ export const defaultMetaAdsDeps = {
7
+ get: (path) => apiGet(path),
8
+ channel: getChannel(),
9
+ apiUrl: () => getApiUrl(),
10
+ now: () => Date.now(),
11
+ };
12
+ export function displayOr(display, value, format) {
13
+ if (typeof display === "string" && display.trim())
14
+ return safeTextOr(display, "—");
15
+ if (typeof value === "number" && Number.isFinite(value))
16
+ return format(value);
17
+ return "—";
18
+ }
19
+ export function money(n, currency) {
20
+ const body = n.toLocaleString("en-US", {
21
+ minimumFractionDigits: n < 100 ? 2 : 0,
22
+ maximumFractionDigits: 2,
23
+ });
24
+ return currency ? `${body} ${currency}` : body;
25
+ }
26
+ export function percent(n) {
27
+ return `${n.toFixed(2)}%`;
28
+ }
29
+ export function count(n) {
30
+ return typeof n === "number" && Number.isFinite(n) ? n.toLocaleString("en-US") : "—";
31
+ }
32
+ const ESC_STRING_SEQUENCE = /\x1b[\]PX^_][\s\S]*?(?:\x07|\x1b\\|$)/g;
33
+ const ESC_CSI_SEQUENCE = /\x1b\[[0-?]*[ -\/]*[@-~]?/g;
34
+ const ESC_SIMPLE_SEQUENCE = /\x1b[ -\/]*[0-~]?/g;
35
+ const C0_AND_DEL = /[\x00-\x08\x0b-\x1f\x7f]/g;
36
+ const C1_CONTROLS = /[\u0080-\u009f]/g;
37
+ export function sanitizeForTerminal(text) {
38
+ if (typeof text !== "string" || text === "")
39
+ return "";
40
+ return text
41
+ .replace(ESC_STRING_SEQUENCE, "")
42
+ .replace(ESC_CSI_SEQUENCE, "")
43
+ .replace(ESC_SIMPLE_SEQUENCE, "")
44
+ .replace(C0_AND_DEL, "")
45
+ .replace(C1_CONTROLS, "");
46
+ }
47
+ export function safeText(text) {
48
+ return sanitizeForTerminal(text).trim();
49
+ }
50
+ export function safeTextOr(text, fallback) {
51
+ return safeText(text) || fallback;
52
+ }
53
+ export function truncate(text, max) {
54
+ if (typeof text !== "string")
55
+ return "";
56
+ const flat = sanitizeForTerminal(text).replace(/\s+/g, " ").trim();
57
+ if (flat.length <= max)
58
+ return flat;
59
+ const cut = flat.slice(0, max - 1);
60
+ const lastSpace = cut.lastIndexOf(" ");
61
+ const body = lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut;
62
+ return `${body}…`;
63
+ }
64
+ export function dateOrDash(value) {
65
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
66
+ const d = new Date(value);
67
+ return Number.isNaN(d.getTime()) ? "—" : d.toISOString().slice(0, 10);
68
+ }
69
+ if (typeof value === "string" && value.trim()) {
70
+ const d = new Date(value);
71
+ return Number.isNaN(d.getTime())
72
+ ? safeText(value).slice(0, 10)
73
+ : d.toISOString().slice(0, 10);
74
+ }
75
+ return "—";
76
+ }
77
+ export function table(headers, rows) {
78
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
79
+ const fmt = (row) => row.map((cell, i) => (cell ?? "").padEnd(widths[i])).join(" ").trimEnd();
80
+ return [fmt(headers), fmt(headers.map((h) => "-".repeat(h.length))), ...rows.map(fmt)].join("\n");
81
+ }
82
+ export function accountLine(account) {
83
+ const id = safeTextOr(account.accountId, "(no id)");
84
+ const name = safeText(account.name);
85
+ const currency = safeText(account.currency);
86
+ const suffix = [name, currency ? `(${currency})` : ""].filter(Boolean).join(" ");
87
+ return suffix ? ` ${id} ${suffix}` : ` ${id}`;
88
+ }
89
+ export function mediaUrl(apiBase, relative) {
90
+ const rel = safeText(relative);
91
+ if (!rel)
92
+ return null;
93
+ if (/^https?:\/\//i.test(rel))
94
+ return rel;
95
+ const base = (apiBase || "").replace(/\/$/, "");
96
+ return `${base}${rel.startsWith("/") ? "" : "/"}${rel}`;
97
+ }
98
+ export function errorCode(data) {
99
+ if (typeof data !== "object" || data === null)
100
+ return undefined;
101
+ const rec = data;
102
+ if (typeof rec.code === "string" && rec.code)
103
+ return rec.code;
104
+ const err = rec.error;
105
+ if (typeof err === "object" && err !== null) {
106
+ const nested = err.code;
107
+ if (typeof nested === "string" && nested)
108
+ return nested;
109
+ }
110
+ return undefined;
111
+ }
112
+ export function accountsOf(data) {
113
+ if (typeof data !== "object" || data === null)
114
+ return [];
115
+ const raw = data.accounts;
116
+ return Array.isArray(raw) ? raw : [];
117
+ }
118
+ export function accountRequiredLines(res, example) {
119
+ const accounts = accountsOf(res.data);
120
+ const lines = [formatApiError(res)];
121
+ if (accounts.length > 0) {
122
+ lines.push("");
123
+ lines.push("Connected ad accounts:");
124
+ for (const account of accounts)
125
+ lines.push(accountLine(account));
126
+ const first = accounts[0]?.accountId;
127
+ if (first) {
128
+ lines.push("");
129
+ lines.push("Re-run naming the one you mean:");
130
+ lines.push(` ${example(first)}`);
131
+ }
132
+ }
133
+ return lines;
134
+ }
135
+ export function errorFor(res, verb, json, deps, example) {
136
+ if (res.ok)
137
+ return undefined;
138
+ if (json) {
139
+ return { code: 1, lines: [JSON.stringify({ ok: false, status: res.status, data: res.data })] };
140
+ }
141
+ const unsupported = missingRouteLine(res, verb, deps.channel);
142
+ if (unsupported)
143
+ return { code: 1, lines: [unsupported] };
144
+ if (res.status === 400 && errorCode(res.data) === "ACCOUNT_REQUIRED") {
145
+ return { code: 1, lines: accountRequiredLines(res, example) };
146
+ }
147
+ const lines = [formatApiError(res)];
148
+ if (res.status === 401) {
149
+ lines.push("");
150
+ lines.push(auth401Hint(deps.apiUrl()));
151
+ }
152
+ else if (res.status === 400 && errorCode(res.data) === "NO_BRAND") {
153
+ lines.push("Pick a brand first: exodus brand use <slug>");
154
+ }
155
+ return { code: 1, lines };
156
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1400",
3
+ "version": "2026.9.1700-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",