@aicopycoders/exodus 2026.7.200-beta.6 → 2026.7.200-beta.7

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.
@@ -0,0 +1,401 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { apiGet, apiPost } from "../lib/client.js";
4
+ import { formatError } from "../lib/format.js";
5
+ import { pollUntilDone } from "../lib/poll.js";
6
+ export const helpText = `
7
+ exodus winners — Import your own brand's winning ads as generative fuel
8
+
9
+ Your agent (holding the Meta Ads MCP) assembles a winner-package JSON of
10
+ designated ad-account winners; this command pushes it into Exodus. Each
11
+ winner lands as a swipe row under an auto-created own-brand entry, enriched
12
+ server-side (Ad Library match → scrape, or your pushed media files), and
13
+ surfaces in generation selection with its verdict.
14
+
15
+ Usage:
16
+ exodus winners import <file.json | -> Push a winner package (- reads stdin)
17
+ exodus winners status <importId> Re-poll an import later
18
+ exodus winners list Winners Exodus already holds
19
+
20
+ Import flags:
21
+ --dry-run Local schema check + server dry-run: reports would-create vs
22
+ would-update per winner. Zero writes, no media upload.
23
+ --no-wait Return the importId immediately instead of polling
24
+ --json Machine-readable JSON output
25
+
26
+ Notes:
27
+ • Scopes to your active brand's workspace (exodus brand current).
28
+ • Requires your Scrape Creators API key (Settings → Keys) — the own-page
29
+ match scrape bills your account.
30
+ • Re-pushing the same file is safe: no duplicate rows, the verdict snapshot
31
+ is replaced wholesale, and previously gap-filled winners that now match
32
+ upgrade in place. Winners absent from a re-push are untouched.
33
+ • assets paths in the package resolve relative to the JSON file's folder.
34
+
35
+ Examples:
36
+ exodus winners import winners.json
37
+ exodus winners import winners.json --dry-run
38
+ cat winners.json | exodus winners import -
39
+ exodus winners status k97abc...
40
+ exodus winners list
41
+ `.trim();
42
+ export function validatePackageLocally(pkg) {
43
+ const errors = [];
44
+ const warnings = [];
45
+ let winnerCount = 0;
46
+ if (pkg === null || typeof pkg !== "object") {
47
+ return { ok: false, errors: ["package is not a JSON object"], warnings, winnerCount };
48
+ }
49
+ const p = pkg;
50
+ if (p.version !== 1)
51
+ errors.push("version must be 1");
52
+ const source = (p.source ?? {});
53
+ if (typeof source.pageId !== "string" || !source.pageId.trim())
54
+ errors.push("source.pageId is required (the scrape target + own-brand key)");
55
+ if (typeof source.pageName !== "string" || !source.pageName.trim())
56
+ errors.push("source.pageName is required (names the own-brand entry)");
57
+ if (typeof source.adAccountId !== "string" || !source.adAccountId.trim())
58
+ errors.push("source.adAccountId is required");
59
+ if (!Array.isArray(p.winners) || p.winners.length === 0) {
60
+ errors.push("winners array is required and must be non-empty");
61
+ return { ok: errors.length === 0, errors, warnings, winnerCount };
62
+ }
63
+ winnerCount = p.winners.length;
64
+ const seen = new Set();
65
+ p.winners.forEach((raw, i) => {
66
+ const label = `winner #${i + 1}`;
67
+ if (raw === null || typeof raw !== "object") {
68
+ warnings.push(`${label}: not an object — the server will reject it`);
69
+ return;
70
+ }
71
+ const w = raw;
72
+ const id = typeof w.accountAdId === "string" ? w.accountAdId : undefined;
73
+ if (!id)
74
+ warnings.push(`${label}: missing accountAdId`);
75
+ else if (seen.has(id))
76
+ warnings.push(`${label} (${id}): duplicate accountAdId`);
77
+ else
78
+ seen.add(id);
79
+ if (w.format !== "video" && w.format !== "image")
80
+ warnings.push(`${label}${id ? ` (${id})` : ""}: format must be "video" or "image"`);
81
+ const verdict = (w.verdict ?? {});
82
+ if (typeof verdict.sentence !== "string" || !verdict.sentence.trim())
83
+ warnings.push(`${label}${id ? ` (${id})` : ""}: missing verdict.sentence`);
84
+ if ((typeof w.bodyText !== "string" || !w.bodyText.trim()) &&
85
+ (typeof w.headline !== "string" || !w.headline.trim()))
86
+ warnings.push(`${label}${id ? ` (${id})` : ""}: needs bodyText or headline`);
87
+ });
88
+ return { ok: errors.length === 0, errors, warnings, winnerCount };
89
+ }
90
+ export function contentTypeFor(filePath) {
91
+ const ext = path.extname(filePath).toLowerCase();
92
+ const map = {
93
+ ".jpg": "image/jpeg",
94
+ ".jpeg": "image/jpeg",
95
+ ".png": "image/png",
96
+ ".webp": "image/webp",
97
+ ".gif": "image/gif",
98
+ ".mp4": "video/mp4",
99
+ ".m4v": "video/mp4",
100
+ ".mov": "video/quicktime",
101
+ ".webm": "video/webm",
102
+ };
103
+ return map[ext] ?? "application/octet-stream";
104
+ }
105
+ export async function run(flags) {
106
+ const positional = parsePositional();
107
+ const [sub, ...rest] = positional;
108
+ if (sub === "import")
109
+ return runImport(rest, flags);
110
+ if (sub === "status")
111
+ return runStatus(rest, flags);
112
+ if (sub === "list")
113
+ return runList(flags);
114
+ if (!sub) {
115
+ console.log(helpText);
116
+ return;
117
+ }
118
+ console.error(`Unknown subcommand: "${sub}"\n`);
119
+ console.log(helpText);
120
+ process.exit(1);
121
+ }
122
+ function parsePositional() {
123
+ return process.argv.slice(3).filter((a) => !a.startsWith("--"));
124
+ }
125
+ async function runImport(positional, flags) {
126
+ const json = !!flags["json"];
127
+ const dryRun = !!flags["dry-run"] || !!flags["dryRun"];
128
+ const noWait = flags["wait"] === false || flags["no-wait"] === true;
129
+ const fileArg = positional[0];
130
+ if (!fileArg) {
131
+ console.error("Error: a package file is required (or - for stdin)");
132
+ console.log("Usage: exodus winners import <file.json | ->");
133
+ process.exit(1);
134
+ }
135
+ let rawText;
136
+ let baseDir;
137
+ if (fileArg === "-") {
138
+ rawText = fs.readFileSync(0, "utf-8");
139
+ baseDir = process.cwd();
140
+ }
141
+ else {
142
+ if (!fs.existsSync(fileArg)) {
143
+ console.error(`Error: file not found: ${fileArg}`);
144
+ process.exit(1);
145
+ }
146
+ rawText = fs.readFileSync(fileArg, "utf-8");
147
+ baseDir = path.dirname(path.resolve(fileArg));
148
+ }
149
+ let pkg;
150
+ try {
151
+ pkg = JSON.parse(rawText);
152
+ }
153
+ catch (err) {
154
+ console.error(`Error: package is not valid JSON: ${err instanceof Error ? err.message : err}`);
155
+ process.exit(1);
156
+ }
157
+ const validation = validatePackageLocally(pkg);
158
+ if (!validation.ok) {
159
+ if (json) {
160
+ console.log(JSON.stringify({ ok: false, errors: validation.errors }));
161
+ }
162
+ else {
163
+ console.error("Package failed local validation:");
164
+ for (const e of validation.errors)
165
+ console.error(` • ${e}`);
166
+ }
167
+ process.exit(1);
168
+ }
169
+ if (!json) {
170
+ for (const w of validation.warnings)
171
+ console.error(`Warning: ${w}`);
172
+ }
173
+ const winners = (pkg.winners ?? []).filter((w) => w !== null && typeof w === "object");
174
+ const missingFiles = [];
175
+ for (const w of winners) {
176
+ const assets = (w.assets ?? {});
177
+ for (const key of ["imagePath", "videoPath", "posterPath"]) {
178
+ const p = assets[key];
179
+ if (typeof p === "string" && p.trim() && !fs.existsSync(path.resolve(baseDir, p))) {
180
+ missingFiles.push(p);
181
+ }
182
+ }
183
+ }
184
+ if (!dryRun && missingFiles.length > 0) {
185
+ console.error("Error: referenced asset file(s) not found:");
186
+ for (const f of missingFiles)
187
+ console.error(` • ${f}`);
188
+ process.exit(1);
189
+ }
190
+ if (dryRun) {
191
+ const res = await apiPost("/api/v2/winners", {
192
+ ...pkg,
193
+ dryRun: true,
194
+ });
195
+ if (!res.ok) {
196
+ if (json)
197
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
198
+ else
199
+ console.log(formatError(res));
200
+ process.exit(1);
201
+ }
202
+ if (json) {
203
+ console.log(JSON.stringify({ ok: true, ...res.data, localWarnings: validation.warnings }));
204
+ return;
205
+ }
206
+ const per = res.data.perWinner ?? [];
207
+ const creates = per.filter((p) => p.action === "create").length;
208
+ const updates = per.filter((p) => p.action === "update").length;
209
+ console.log("Dry run — no writes, no Trigger fire, no media upload.");
210
+ console.log(res.data.wouldCreateBrand
211
+ ? " own-brand entry: would be created"
212
+ : ` own-brand entry: exists (${res.data.brandName ?? "?"})`);
213
+ console.log(` winners: ${creates} would-create, ${updates} would-update`);
214
+ for (const p of per)
215
+ console.log(` ${p.accountAdId} ${p.action}`);
216
+ for (const r of res.data.rejected ?? []) {
217
+ console.log(` ${r.accountAdId} would-reject (${r.reason})`);
218
+ }
219
+ if (res.data.scrapecreatorsKey === "missing") {
220
+ console.log("\nHeads up: no Scrape Creators key on your account — a real import will fail.");
221
+ console.log("Add it in Settings → Keys (get one at scrapecreators.com).");
222
+ }
223
+ return;
224
+ }
225
+ for (const w of winners) {
226
+ const assets = (w.assets ?? {});
227
+ const swapped = {};
228
+ const pairs = [
229
+ ["imagePath", "imageStorageId"],
230
+ ["videoPath", "videoStorageId"],
231
+ ["posterPath", "posterStorageId"],
232
+ ];
233
+ for (const [pathKey, idKey] of pairs) {
234
+ const rel = assets[pathKey];
235
+ if (typeof rel !== "string" || !rel.trim())
236
+ continue;
237
+ const abs = path.resolve(baseDir, rel);
238
+ const storageId = await uploadAsset(abs, json);
239
+ if (!storageId)
240
+ process.exit(1);
241
+ swapped[idKey] = storageId;
242
+ }
243
+ if (Object.keys(swapped).length > 0) {
244
+ w.assets = swapped;
245
+ }
246
+ else {
247
+ delete w.assets;
248
+ }
249
+ }
250
+ const res = await apiPost("/api/v2/winners", pkg);
251
+ if (!res.ok || !res.data.importId) {
252
+ if (json)
253
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
254
+ else
255
+ console.log(formatError(res));
256
+ process.exit(1);
257
+ }
258
+ const importId = res.data.importId;
259
+ if (noWait) {
260
+ if (json) {
261
+ console.log(JSON.stringify({ ok: true, importId, triggerRunId: res.data.triggerRunId, accepted: res.data.accepted, rejected: res.data.rejected }));
262
+ return;
263
+ }
264
+ console.log(`Import started: ${importId}`);
265
+ console.log(` accepted: ${res.data.accepted ?? "?"} winner(s)`);
266
+ for (const r of res.data.rejected ?? []) {
267
+ console.log(` rejected: ${r.accountAdId} (${r.reason})`);
268
+ }
269
+ console.log(`\nPoll: exodus winners status ${importId}`);
270
+ return;
271
+ }
272
+ if (!json) {
273
+ console.log(`Import started: ${importId} — waiting for outcomes…`);
274
+ }
275
+ let lastStatus = "";
276
+ const poll = await pollUntilDone({
277
+ path: `/api/v2/winners/imports/${importId}`,
278
+ terminalStatuses: ["done"],
279
+ onProgress: (data) => {
280
+ const status = typeof data.status === "string" ? data.status : "";
281
+ if (!json && status && status !== lastStatus) {
282
+ lastStatus = status;
283
+ console.log(` status: ${status}`);
284
+ }
285
+ },
286
+ });
287
+ const outcome = poll.data;
288
+ if (json) {
289
+ console.log(JSON.stringify({ ok: poll.ok, timedOut: poll.timedOut, ...outcome }));
290
+ if (!poll.ok)
291
+ process.exit(1);
292
+ return;
293
+ }
294
+ if (poll.timedOut) {
295
+ console.log(`Timed out waiting. Re-poll: exodus winners status ${importId}`);
296
+ process.exit(1);
297
+ }
298
+ printOutcomeTable(outcome);
299
+ if (!poll.ok)
300
+ process.exit(1);
301
+ }
302
+ async function uploadAsset(absPath, json) {
303
+ const upRes = await apiPost("/api/v2/winners/upload-url", {});
304
+ if (!upRes.ok || !upRes.data.uploadUrl) {
305
+ if (json)
306
+ console.log(JSON.stringify({ ok: false, step: "upload-url", data: upRes.data }));
307
+ else
308
+ console.log(formatError(upRes));
309
+ return null;
310
+ }
311
+ const bytes = fs.readFileSync(absPath);
312
+ const put = await fetch(upRes.data.uploadUrl, {
313
+ method: "POST",
314
+ headers: { "Content-Type": contentTypeFor(absPath) },
315
+ body: new Blob([bytes]),
316
+ });
317
+ if (!put.ok) {
318
+ const text = await put.text().catch(() => "(unreadable)");
319
+ if (json)
320
+ console.log(JSON.stringify({ ok: false, step: "upload", status: put.status }));
321
+ else
322
+ console.error(`Error uploading ${path.basename(absPath)}: ${put.status} ${text.slice(0, 200)}`);
323
+ return null;
324
+ }
325
+ const { storageId } = (await put.json());
326
+ if (!storageId) {
327
+ console.error(`Error: no storageId returned for ${path.basename(absPath)}`);
328
+ return null;
329
+ }
330
+ if (!json)
331
+ console.log(` uploaded ${path.basename(absPath)}`);
332
+ return storageId;
333
+ }
334
+ function printOutcomeTable(data) {
335
+ const winners = data.winners ?? [];
336
+ console.log(`\nImport ${data.importId ?? "?"}: ${data.status ?? "?"}`);
337
+ if (data.error)
338
+ console.log(` error: ${data.error}`);
339
+ const created = winners.filter((w) => w.created === true).length;
340
+ const updated = winners.filter((w) => w.created === false).length;
341
+ console.log(` ${winners.length} winner(s): ${created} created, ${updated} updated`);
342
+ for (const w of winners) {
343
+ let detail = "";
344
+ if (w.outcome === "partial" && w.missing?.length)
345
+ detail = ` (missing: ${w.missing.join(", ")})`;
346
+ if (w.outcome === "rejected" && w.reason)
347
+ detail = ` (${w.reason})`;
348
+ console.log(` ${w.accountAdId} ${w.outcome}${detail}`);
349
+ }
350
+ }
351
+ async function runStatus(positional, flags) {
352
+ const importId = positional[0];
353
+ if (!importId) {
354
+ console.error("Error: importId is required");
355
+ console.log("Usage: exodus winners status <importId>");
356
+ process.exit(1);
357
+ }
358
+ const json = !!flags["json"];
359
+ const res = await apiGet(`/api/v2/winners/imports/${encodeURIComponent(importId)}`);
360
+ if (!res.ok) {
361
+ if (json)
362
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
363
+ else
364
+ console.log(formatError(res));
365
+ process.exit(1);
366
+ }
367
+ if (json) {
368
+ console.log(JSON.stringify({ ok: true, ...res.data }));
369
+ return;
370
+ }
371
+ printOutcomeTable(res.data);
372
+ }
373
+ async function runList(flags) {
374
+ const json = !!flags["json"];
375
+ const res = await apiGet("/api/v2/winners");
376
+ if (!res.ok) {
377
+ if (json)
378
+ console.log(JSON.stringify({ ok: false, status: res.status, data: res.data }));
379
+ else
380
+ console.log(formatError(res));
381
+ process.exit(1);
382
+ }
383
+ const winners = res.data.winners ?? [];
384
+ if (json) {
385
+ console.log(JSON.stringify({ ok: true, winners }));
386
+ return;
387
+ }
388
+ if (winners.length === 0) {
389
+ console.log("No imported winners yet. Push some: exodus winners import <file.json>");
390
+ return;
391
+ }
392
+ console.log(`Winners (${winners.length}):`);
393
+ for (const w of winners) {
394
+ const status = w.enrichmentStatus === "partial"
395
+ ? `partial${w.enrichmentMissing?.length ? ` (missing: ${w.enrichmentMissing.join(", ")})` : ""}`
396
+ : (w.enrichmentStatus ?? "?");
397
+ const when = w.designatedAt ? new Date(w.designatedAt).toLocaleDateString() : "?";
398
+ console.log(` ${w.sourceAdId ?? w.id} [${w.format}] ${status} designated=${when}`);
399
+ console.log(` ${w.verdictSentence}`);
400
+ }
401
+ }
@@ -1,7 +1,7 @@
1
1
  import { type ApiResponse } from "../lib/client.js";
2
2
  import { type PollOptions, type PollResult } from "../lib/poll.js";
3
3
  export declare const helpText: string;
4
- export type WorkflowNodeKind = "brief" | "bot" | "primer" | "image" | "output";
4
+ export type WorkflowNodeKind = "brief" | "bot" | "primer" | "image" | "rig" | "storyboard" | "reference" | "scene-frames" | "video" | "voiceover" | "output";
5
5
  export interface WorkflowNode {
6
6
  id: string;
7
7
  kind: WorkflowNodeKind;
@@ -59,8 +59,22 @@ export interface WorkflowImportError {
59
59
  issues?: GraphIssue[];
60
60
  currentUpdatedAt?: string;
61
61
  }
62
- export type WorkflowPortType = "text" | "primer" | "image";
62
+ export type WorkflowPortType = "text" | "primer" | "image" | "rig" | "storyboard" | "frames" | "video" | "audio";
63
63
  export type WorkflowPrimerKind = "body" | "hook" | "headline" | "summary";
64
+ export type WorkflowDurationSpec = {
65
+ kind: "fixed";
66
+ values: number[];
67
+ } | {
68
+ kind: "range";
69
+ min: number;
70
+ max: number;
71
+ };
72
+ export interface CatalogVideoModel {
73
+ id: string;
74
+ label: string;
75
+ durations: WorkflowDurationSpec;
76
+ audioTogglable: boolean;
77
+ }
64
78
  export type WorkflowParamKind = "select" | "text" | "textarea" | "toggle" | "number" | "multiselect";
65
79
  export interface CatalogInput {
66
80
  id: string;
@@ -103,6 +117,7 @@ export interface WorkflowCatalog {
103
117
  imageModels: string[];
104
118
  aspectRatios: string[];
105
119
  imageQuantityModes: string[];
120
+ videoModels: CatalogVideoModel[];
106
121
  categories: Array<{
107
122
  id: string;
108
123
  label: string;
@@ -130,7 +145,7 @@ export interface WorkflowPrerequisiteDescriptor {
130
145
  nodeIds: string[];
131
146
  }
132
147
  export interface WorkflowOutputDescriptor {
133
- type: "text" | "image";
148
+ type: "text" | "image" | "video" | "audio" | "frames" | "storyboard";
134
149
  label: string;
135
150
  nodeId: string;
136
151
  botSlug?: string;
@@ -154,11 +169,13 @@ export interface WorkflowListItem {
154
169
  edgeCount: number;
155
170
  createdAt: string;
156
171
  updatedAt: string;
172
+ isCrossBrand?: boolean;
173
+ homeBrandName?: string | null;
157
174
  }
158
175
  export interface WorkflowListResponse {
159
176
  workflows: WorkflowListItem[];
160
177
  }
161
- export type WorkflowRunStatus = "queued" | "running" | "completed" | "partial" | "failed";
178
+ export type WorkflowRunStatus = "queued" | "running" | "awaiting-review" | "completed" | "partial" | "failed" | "canceled";
162
179
  export type WorkflowNodeRunStatus = "idle" | "running" | "done" | "failed" | "skipped";
163
180
  export type WorkflowArtifact = {
164
181
  type: "text";
@@ -176,11 +193,21 @@ export type WorkflowArtifact = {
176
193
  export interface WorkflowRunOutput {
177
194
  nodeId: string;
178
195
  botSlug?: string;
179
- type: "text" | "image";
196
+ type: "text" | "image" | "video" | "audio" | "frames" | "storyboard";
180
197
  label: string;
181
198
  text?: string;
182
199
  imageUrl?: string;
183
200
  imageId?: string;
201
+ videoUrl?: string;
202
+ audioUrl?: string;
203
+ durationSec?: number;
204
+ sceneIndex?: number;
205
+ final?: boolean;
206
+ frames?: Array<{
207
+ sceneIndex: number;
208
+ imageUrl?: string;
209
+ }>;
210
+ storyboardJson?: string;
184
211
  }
185
212
  export interface WorkflowRunNode {
186
213
  nodeId: string;
@@ -171,7 +171,7 @@ export function formatWorkflowList(workflows) {
171
171
  if (workflows.length === 0)
172
172
  return "No workflows found for the active brand.";
173
173
  return table(["name", "nodes", "edges", "updated", "id"], workflows.map((w) => [
174
- w.name,
174
+ w.isCrossBrand && w.homeBrandName ? `${w.name} · from ${w.homeBrandName}` : w.name,
175
175
  String(w.nodeCount),
176
176
  String(w.edgeCount),
177
177
  dateOnly(w.updatedAt),
@@ -252,6 +252,20 @@ function runOutputLines(output) {
252
252
  if (output.type === "image") {
253
253
  return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}`];
254
254
  }
255
+ if (output.type === "video") {
256
+ const tag = output.final === true ? "final video" : "video";
257
+ return [` ${output.label} [${tag}]${slug}: ${output.videoUrl ?? "(no url)"}`];
258
+ }
259
+ if (output.type === "audio") {
260
+ return [` ${output.label} [audio]${slug}: ${output.audioUrl ?? "(no url)"}`];
261
+ }
262
+ if (output.type === "frames") {
263
+ const n = output.frames?.length ?? 0;
264
+ return [` ${output.label} [frames]${slug}: ${n} scene${n === 1 ? "" : "s"}`];
265
+ }
266
+ if (output.type === "storyboard") {
267
+ return [` ${output.label} [storyboard]${slug}: use --json for the scene plan`];
268
+ }
255
269
  const raw = output.text ?? "";
256
270
  const normalized = raw.replace(/\s+/g, " ").trim();
257
271
  const body = truncateText(raw, 400);
@@ -523,14 +537,19 @@ export async function runFlow(workflowRef, opts, deps) {
523
537
  lines.length = 0;
524
538
  }
525
539
  const seen = new Map();
540
+ let pausedNotified = false;
526
541
  const pollResult = await deps.poll({
527
542
  path: `${STATUS_PATH}?runId=${encodeURIComponent(data.runId)}`,
528
543
  intervalMs: 3_000,
529
544
  timeoutMs: 60 * 60 * 1000,
530
- terminalStatuses: ["completed", "partial", "failed"],
545
+ terminalStatuses: ["completed", "partial", "failed", "canceled"],
531
546
  onProgress: (raw) => {
532
547
  if (opts.json || !opts.onProgressLine)
533
548
  return;
549
+ if (raw["status"] === "awaiting-review" && !pausedNotified) {
550
+ pausedNotified = true;
551
+ opts.onProgressLine(" ⏸ paused at the cost gate — approve or edit the storyboard in the web app to continue.");
552
+ }
534
553
  const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
535
554
  for (const node of nodes) {
536
555
  if (seen.get(node.nodeId) === node.status)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.7.200-beta.6",
3
+ "version": "2026.7.200-beta.7",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",