@aicopycoders/exodus 2026.9.1400-beta.0 → 2026.9.1400-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.
@@ -64,6 +64,7 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
64
64
  | **Video** | `exodus-video` | Make a video ad from a Show: start a run from a script, read and approve the storyboard, `npx @aicopycoders/exodus video pull` every piece (per-scene clips, voices, keyframes, word timings, music bed) plus a manifest, cut them with the bundled first-cut script, upload the cut and approve it. Admin-only. |
65
65
  | **Browse** | `exodus-browse` | View history and retrieve past outputs |
66
66
  | **Drive** | `exodus-drive` | Read/write Google Docs, Sheets, and Drive files via `npx @aicopycoders/exodus drive` (uses the dashboard's Google OAuth — no local CLI) |
67
+ | **Swipe / Mining** | (no skill; CLI only) | The competitor watch list behind the Mining page: `npx @aicopycoders/exodus swipe brands list`, `swipe brands bulk-import <csv>` (checks each brand and starts collecting its ads), `swipe brands add "<Name>" --fb <pageId>` (adds without starting research, #1366), `swipe run` / `swipe mine`. There is no `competitors` verb. |
67
68
  | **Ads** | `exodus-ads` | Read your own live Meta ads once the account is connected on the dashboard — `npx @aicopycoders/exodus ads list` (sort by cost per result, spend or CTR), `ads show <adId>` (copy, both windows, media links, comment counts), `npx @aicopycoders/exodus comments list` (already commenter-redacted), and `npx @aicopycoders/exodus winners definition` (what this brand means by a winner). Read-only; no Meta login or MCP needed. |
68
69
  | **Winners** | `exodus-winners` | Mine your own Meta ad account for winning ads and import them into Exodus as generative fuel — interview, visual confirmation, `npx @aicopycoders/exodus winners import`. A brand whose Meta account is connected reads its ads and its winner definition straight from Exodus; only an unconnected brand needs the Meta Ads MCP. |
69
70
 
@@ -39,6 +39,7 @@ This skill ROUTES. Read what the user wants, name the destination skill, and han
39
39
  | Build a bot or a workflow prompt node | `bot-builder` |
40
40
  | "remember that", "never do that again" | `remember-that` |
41
41
  | "handoff", "wrap up", "what did we do today" | `handoff` |
42
+ | Add competitors to the Mining page / watch list, see who is on it ("add these brands to mining", "put Brace Direct on the watch list") | run `npx @aicopycoders/exodus swipe brands bulk-import <csv>` (one brand: `swipe brands add "<Name>" --fb <pageId>`; `swipe brands list` to see the list). There is no `competitors` verb. |
42
43
  | Update the CLI + skills ("update exodus", "run an exodus update") | run `npx @aicopycoders/exodus@latest update` — the ONLY command that refreshes skills; `doctor` checks health but never rewrites them |
43
44
 
44
45
  The method skills above are Luke's thinking as skills; they run as a conversation and are listed in `CLAUDE.md` under **Method skills**. If a custom pipeline skill is installed in this folder (any additional `exodus-*` directory under `.claude/skills/`), it routes the same way — match the user's words to that skill's description.
@@ -7,7 +7,7 @@ description: Find the competitors that are actually strong (scaling and working)
7
7
 
8
8
  A competitor is worth following when they are scaling and working, not when they are in the same category. The test is measurable and the search is by mechanism. Everything else here exists to make the first hour not depend on knowing the tools.
9
9
 
10
- Think mode until the plan-back. The sweep and the signal check are build bursts: say the plan and the cost back in two lines, get the go, run them comprehensively, write files as they land, report spend. Then back to a short list he can cut. Read the `thinking-vs-build` skill if installed.
10
+ Think mode until the plan-back. The sweep and the signal check are build bursts: say the plan and the cost back in two lines, get the go, run them comprehensively, write files as they land, report spend. Then back to a short list the user can cut. Read the `thinking-vs-build` skill if installed.
11
11
 
12
12
  ## What "strong" means
13
13
 
@@ -128,6 +128,8 @@ Thresholds used: <…>. Verdict: FOLLOW 3+ · WATCH 2 · SKIP 0–1.
128
128
 
129
129
  The sheet lives in the folder. The FOLLOW rows become the watch list: an Ad Library page link per brand (the shape above) saved as a links file the rip can consume, and the brands added to Atria's followed list by hand in the app. When the brand is set up in Exodus, the Scout feed covers the Instagram side.
130
130
 
131
+ **Push the watch list into Exodus (the Mining page).** The CLI verb is `npx @aicopycoders/exodus swipe brands`, not `competitors`. Write the FOLLOW rows as a CSV (`name,igHandle,fbPageId,category[,website][,youtubeHandle]`; the Ad Library `view_all_page_id` is the `fbPageId`) and run `npx @aicopycoders/exodus swipe brands bulk-import <file.csv>`: it checks each brand's page on the member's own Scrape Creators key and starts collecting its ads. `swipe brands add "<Name>" --fb <pageId>` adds one brand but does not start the research yet (open issue #1366), so prefer the CSV route even for one brand. `swipe brands list` shows what is on the page.
132
+
131
133
  ## Hand-offs
132
134
 
133
135
  - **Rip:** the FOLLOW rows and their longest-running ads go to the swipe process (transcript, headline, body, rank, on-screen text) and into the ad bank next to the brand's own ads.
@@ -11,6 +11,7 @@ lanes.txt: one query per line (the outcome × mechanism × ingredient grid, see
11
11
 
12
12
  Needs APIFY_API_TOKEN in .env. Cost: the apify~facebook-ads-scraper actor bills per result; 30 lanes × 150
13
13
  results is a few dollars. Without --yes the script prints the plan and stops; say it back, get a go, rerun with --yes.
14
+ A lane that already has a JSON file is skipped, so a rerun after failures only bills the failed lanes.
14
15
 
15
16
  Output:
16
17
  <outdir>/<lane>.json raw ads per lane
@@ -37,15 +38,22 @@ def run(kw, outdir, limit, country, tok):
37
38
  u = ("https://www.facebook.com/ads/library/?active_status=active&ad_type=all&country=" + country +
38
39
  "&q=" + urllib.parse.quote(kw) + "&search_type=keyword_unordered&media_type=all")
39
40
  path = os.path.join(outdir, kw.replace(" ", "_").replace("/", "-") + ".json")
41
+ if os.path.exists(path):
42
+ print(f"{kw:<40} already swept, skipped (delete the file to redo)", flush=True)
43
+ return True
40
44
  try:
41
45
  r = requests.post(f"https://api.apify.com/v2/acts/apify~facebook-ads-scraper/run-sync-get-dataset-items?token={tok}&timeout=600",
42
46
  json={"startUrls": [{"url": u}], "resultsLimit": limit}, timeout=(30, 660))
43
- d = r.json() if r.status_code == 200 else []
44
- if not isinstance(d, list): d = []
47
+ r.raise_for_status()
48
+ d = r.json()
49
+ if not isinstance(d, list):
50
+ raise ValueError(f"unexpected response shape: {type(d).__name__}")
45
51
  json.dump(d, open(path, "w"))
46
52
  print(f"{kw:<40} {r.status_code} {len(d)} ads", flush=True)
53
+ return True
47
54
  except Exception as e:
48
- print(kw, "ERR", e, flush=True)
55
+ print(f"{kw:<40} FAILED {e}", flush=True)
56
+ return False
49
57
 
50
58
 
51
59
  def aggregate(outdir):
@@ -75,7 +83,7 @@ def aggregate(outdir):
75
83
 
76
84
  def main():
77
85
  ap = argparse.ArgumentParser()
78
- ap.add_argument("--keywords", required=True)
86
+ ap.add_argument("--keywords", help="one query per line; required unless --aggregate-only")
79
87
  ap.add_argument("--outdir", default="adlib")
80
88
  ap.add_argument("--limit", type=int, default=150)
81
89
  ap.add_argument("--country", default="US")
@@ -85,6 +93,7 @@ def main():
85
93
  a = ap.parse_args()
86
94
  os.makedirs(a.outdir, exist_ok=True)
87
95
  if not a.aggregate_only:
96
+ if not a.keywords: sys.exit("--keywords <file> is required for a sweep")
88
97
  tok = os.environ.get("APIFY_API_TOKEN") or sys.exit("APIFY_API_TOKEN not found in .env or environment")
89
98
  lanes = [l.strip() for l in open(a.keywords) if l.strip() and not l.startswith("#")]
90
99
  if not a.yes:
@@ -92,7 +101,13 @@ def main():
92
101
  print(f"Outputs land in {a.outdir}/. Nothing was run. Say the plan back, get a go, then rerun with --yes.")
93
102
  sys.exit(2)
94
103
  with ThreadPoolExecutor(a.workers) as ex:
95
- list(ex.map(lambda k: run(k, a.outdir, a.limit, a.country, tok), lanes))
104
+ ok = list(ex.map(lambda k: run(k, a.outdir, a.limit, a.country, tok), lanes))
105
+ failed = [k for k, good in zip(lanes, ok) if not good]
106
+ if failed:
107
+ print(f"\n{len(failed)} of {len(lanes)} lanes failed; not aggregating an incomplete sweep.")
108
+ print("Rerun the same command with --yes: finished lanes are skipped, only the failed ones are billed again.")
109
+ print("Failed: " + ", ".join(failed))
110
+ sys.exit(1)
96
111
  aggregate(a.outdir)
97
112
 
98
113
 
@@ -64,9 +64,9 @@ def get(path, params, tries=5):
64
64
 
65
65
  def search_brand(name):
66
66
  items = get("/brand-library/search", {"keyword": name, "page_size": 8}).get("items", [])
67
- first = name.lower().split()[0]
68
- exact = [i for i in items if i.get("name", "").lower() == name.lower()]
69
- close = [i for i in items if first in i.get("name", "").lower()]
67
+ want = name.lower().strip()
68
+ exact = [i for i in items if i.get("name", "").lower().strip() == want]
69
+ close = [i for i in items if want in i.get("name", "").lower()]
70
70
  if exact or close:
71
71
  return (exact or close)[:1]
72
72
  # no name match: do not guess. Report what Atria returned so the user can pick.
@@ -151,6 +151,8 @@ def main():
151
151
  time.sleep(1.0)
152
152
  order = {"FOLLOW": 0, "WATCH": 1, "SKIP": 2}
153
153
  rows.sort(key=lambda r: (order.get(r.get("verdict"), 3), -(r.get("active_total") or 0)))
154
+ for target in [a.out, a.json]:
155
+ if target and os.path.dirname(target): os.makedirs(os.path.dirname(target), exist_ok=True)
154
156
  with open(a.out, "w") as f:
155
157
  f.write(f"# Competitor sheet ({dt.date.today()})\n\nVerdict: FOLLOW = 3+ strong signals · WATCH = 2 · SKIP = 0–1. Thresholds: {STRONG}\n\n")
156
158
  f.write("| Brand | Verdict | Active | Longest (d) | Launches 30d | Reuse | Offer | Strong on | Longest-running ad |\n|---|---|---|---|---|---|---|---|---|\n")
@@ -307,7 +307,14 @@ async function runBrandsAdd(positional, flags) {
307
307
  console.log(formatError(res));
308
308
  process.exit(1);
309
309
  }
310
- console.log(`Added "${name}".`);
310
+ if (res.data.researching) {
311
+ console.log(`Added "${name}". Finding its Ad Library page and mining its ads now.`);
312
+ }
313
+ else {
314
+ console.log(`Added "${name}".`);
315
+ if (res.data.warning)
316
+ console.log(` ${res.data.warning}`);
317
+ }
311
318
  }
312
319
  async function runBrandsRemove(positional) {
313
320
  const id = positional[0];
@@ -97,6 +97,7 @@ export function ensureBrandDir(root, brand) {
97
97
  const created = !fs.existsSync(path.join(dir, BRAND_MARKER_FILE));
98
98
  fs.mkdirSync(path.join(dir, "state"), { recursive: true });
99
99
  fs.mkdirSync(path.join(dir, "output"), { recursive: true });
100
+ fs.mkdirSync(path.join(dir, "inputs"), { recursive: true });
100
101
  const marker = { slug: brand.slug, name: brand.name ?? brand.slug };
101
102
  const markerPath = path.join(dir, BRAND_MARKER_FILE);
102
103
  const existing = readBrandMarker(dir);
@@ -115,6 +115,7 @@ export function ensureBaseDirs(root) {
115
115
  if (!fs.existsSync(keep))
116
116
  fs.writeFileSync(keep, "");
117
117
  fs.mkdirSync(path.join(root, "state"), { recursive: true });
118
+ fs.mkdirSync(path.join(root, "inputs"), { recursive: true });
118
119
  const exodusDir = path.join(root, ".exodus");
119
120
  fs.mkdirSync(exodusDir, { recursive: true });
120
121
  const statePath = path.join(exodusDir, "state.json");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.9.1400-beta.0",
3
+ "version": "2026.9.1400-beta.1",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",