@aicopycoders/exodus 2026.8.1600 → 2026.9.200

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.
package/README.md CHANGED
@@ -46,6 +46,9 @@ health, CLI currency — and tells you exactly what to fix.
46
46
  - `npx -y @aicopycoders/exodus@beta workflow --help` — run, build, and automate
47
47
  saved multi-node workflows; resolve gate/repair parks from the inbox; read
48
48
  banks and promote winners.
49
+ - `npx -y @aicopycoders/exodus@beta hooks --help` — browse the Organic Scout
50
+ hook library: filter by score, lane, validation status and more; explain a
51
+ score; find cards sharing a hook pattern; export to CSV or JSON. Read-only.
49
52
 
50
53
  Inside Claude Code, just say **"exodus"** plus what you want
51
54
  ("exodus, write me some ads") and the skills route the rest.
@@ -60,6 +60,7 @@ These are the **Exodus** pipelines — the automatic system that drives the Gene
60
60
  | **Template** | `exodus-template` | Finished ad copy → many static ads across 50 structured formats (testimonial, hero, UGC…). Otherwise start at **Image**. |
61
61
  | **Meme** | `exodus-meme` | Meme ads — recommends formats for a brief, then one batched run renders AI-image and classic Imgflip-template memes server-side. |
62
62
  | **Workflow** | `exodus-workflow` | Run and build saved multi-node workflow automations from the CLI, resolve checkpoint/repair parks from the inbox, continue bot sessions, fire triggers, and read banks or promote winners. |
63
+ | **Hooks** | `exodus-hooks` | Read the Scout library — the hook cards captured from Instagram outliers. Compose filters (score, lane, validation status, language, hook type, recency), inspect one card, find cards sharing a hook pattern, export to CSV. Read-only. |
63
64
  | **Browse** | `exodus-browse` | View history and retrieve past outputs |
64
65
  | **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) |
65
66
  | **Winners** | `exodus-winners` | Mine your own Meta ad account for winning ads (via the Meta Ads MCP) and import them into Exodus as generative fuel — interview, visual confirmation, `npx @aicopycoders/exodus winners import` |
@@ -15,6 +15,7 @@ In Claude Code you don't click buttons. You say **"exodus"** plus what you want
15
15
  | exodus-template | Ad-Type Format Variations across 50 structured formats |
16
16
  | exodus-meme | Meme Ad Generator — recommend formats, one batched server-side run |
17
17
  | exodus-workflow | Run/build saved multi-node workflows, resolve inbox gate/repair parks, continue sessions, fire triggers, read banks + promote winners |
18
+ | exodus-hooks | Read the Scout library of hook cards — composable filters, one-card detail, pattern matches, CSV export (read-only) |
18
19
  | exodus-browse | View History and Surface the Right Run |
19
20
  | exodus-drive | Google Drive, Docs, Sheets via the Dashboard's OAuth |
20
21
  | exodus-winners | Mine your own Meta ad account for winners (needs the Meta Ads MCP) and import them into Exodus |
@@ -197,6 +198,30 @@ Every workflow/session/bank verb takes --json — that output is the machine API
197
198
 
198
199
  ---
199
200
 
201
+ ## exodus-hooks
202
+
203
+ **What it does:** Reads the Scout library — the brand's pool of hook cards captured from Instagram outliers. Filters compose, so one query answers "10×+, on-screen hooks, Spanish, under 14 days". Read-only; promoting a card is a dashboard act.
204
+
205
+ ```operator-guide
206
+ Commands:
207
+ exodus hooks list [filters] [--limit n] [--json] the filtered slice
208
+ exodus hooks show <ref> [--json] one full card
209
+ exodus hooks find-similar <ref> [--json] cards sharing the hook pattern
210
+ exodus hooks explain-score <ref> the evidence behind the score
211
+ exodus hooks export --csv|--json [filters] [--out f] the whole filtered corpus
212
+ Filters (compose):
213
+ --min-score N · --lane relative|absolute|velocity|evergreen
214
+ --status unproven|corroborated|cross-validated|saturated|virgin-market
215
+ --lifecycle new|reviewed|promoted|tested|killed · --creator <handle>
216
+ --language xx · --market xx · --via paste|seed|expansion|spotter · --days N
217
+ --extract pending|complete|partial|failed|skipped · --has spoken|onscreen|caption
218
+ --pattern "text" · --limit N (default 50, max 200)
219
+ <ref>: card id, Instagram shortcode, or post URL
220
+ Returns: a table, --json data, or an exported CSV/JSON file
221
+ ```
222
+
223
+ ---
224
+
200
225
  ## exodus-browse
201
226
 
202
227
  **What it does:** View past pipeline runs and pull outputs from previous sessions.
@@ -24,6 +24,7 @@ This skill ROUTES. Read what the user wants, name the destination skill, and han
24
24
  | Read copy banks or promote a winner ("show my hook bank", "list my banks", "promote this winner") | `exodus-workflow` |
25
25
  | Onboard a new brand / build the primer | `exodus-primer` (with winning ads) or `exodus-foundation` (no ads yet) |
26
26
  | List/switch/troubleshoot brands | `exodus-brand` |
27
+ | Read the Scout hook library — "what do my 15×+ hooks have in common", "show me my Spanish on-screen hooks", "why did that reel score 22×", "export my hooks to a spreadsheet" | `exodus-hooks` (read-only; promoting a card is a dashboard act) |
27
28
  | See past runs / find an output | `exodus-browse` |
28
29
  | Open or create Google Docs/Sheets | `exodus-drive` |
29
30
  | Import their OWN winning ads from their Meta ad account ("import my winners", "mine my ad account for winners", "exodus winners") | `exodus-winners` |
@@ -0,0 +1,133 @@
1
+ ---
2
+ name: exodus-hooks
3
+ description: Read the brand's Scout library — the pool of hook cards captured from Instagram outliers — through the read-only `npx @aicopycoders/exodus hooks` command family. Use it to answer questions about what's working organically: "exodus, what do my 15×+ hooks have in common?", "exodus, show me my Spanish on-screen hooks from the last two weeks", "exodus, why did that reel score 22×?", "exodus, find other cards with the same hook pattern", "exodus, export my hook library to a spreadsheet". The power is in composing filters — score, lane, validation status, language, market, creator, hook type, recency, discovery door — into one query, then reading the cards back and finding the pattern. Only invoke when the user has explicitly invoked Exodus: they said "exodus" in the request, named this skill or /exodus-hooks, ran an `npx @aicopycoders/exodus hooks` command, or the `exodus` hub skill routed here. Never claim generic hook questions ("what makes a good hook", "write me some hooks") — hook *writing* belongs to `exodus-genesis`/`exodus-write` and hook *judgment* to `exodus-strategist`; in shared folders generic asks may belong to the user's other tools. A hook card is NOT an Idea Bank card — `exodus idea` questions ("what's in my idea bank", "write from idea G3") are a different object with a different lifecycle and do not route here.
4
+ ---
5
+
6
+ ```operator-guide
7
+ Read-only. Nothing in this family changes a card — promoting a hook is a dashboard act.
8
+
9
+ npx @aicopycoders/exodus hooks list [filters] [--limit n] [--json]
10
+ npx @aicopycoders/exodus hooks show <ref> [--json]
11
+ npx @aicopycoders/exodus hooks find-similar <ref> [--json]
12
+ npx @aicopycoders/exodus hooks explain-score <ref>
13
+ npx @aicopycoders/exodus hooks export --csv|--json [same filters as list] [--out <file>]
14
+
15
+ Filters (compose freely — each one narrows the same query):
16
+ --min-score N viral score at or above N (10 = "did 10× the creator's normal")
17
+ --lane relative|absolute|velocity|evergreen which detection lane caught it
18
+ --status unproven|corroborated|cross-validated|saturated|virgin-market
19
+ --lifecycle new|reviewed|promoted|tested|killed
20
+ --creator <handle> one creator (no @)
21
+ --language xx the post's language (es, pt, en …)
22
+ --market xx the market it was seen in
23
+ --via paste|seed|expansion|spotter which door captured it
24
+ --days N captured in the last N days
25
+ --extract pending|complete|partial|failed|skipped hook-extraction state
26
+ --has spoken|onscreen|caption that hook type is present
27
+ --pattern "text" match against the hook pattern
28
+ --limit N default 50, max 200
29
+
30
+ <ref> is a card id, an Instagram shortcode, or the post URL — all three work.
31
+ Returns: a table by default, structured data with --json, a file with export.
32
+ ```
33
+
34
+ # Hooks — read the Scout library
35
+
36
+ ## Strategic Context
37
+
38
+ The **Scout library** is the brand's pool of **hook cards** — one card per captured Instagram outlier, carrying the three verbatim hooks (spoken, on-screen, caption) with their type tags, the **hook pattern** (the hook with its blanks exposed: "I'm {age}, I tried {thing} for {duration} — here's what happened"), the format skeleton, the viral score with its evidence sentence, the validation status, language and market, the relevance tag, the transcript, and the source link.
39
+
40
+ The dashboard shows that library as five bucket rails with filter chips. This CLI family is the *other* window on the same cards — the one where filters compose. That's the whole point: the dashboard answers "show me my breakouts"; `hooks list` answers "10×+, on-screen hooks, Spanish, captured in the last two weeks" in one query. When a member asks a pattern question, you are not browsing for them — you are pulling the right slice and telling them what it says.
41
+
42
+ ## When to Use
43
+
44
+ - "What do my 15×+ hooks have in common?"
45
+ - "Which hook type is winning — spoken, on-screen, or caption?"
46
+ - "Show me the cross-validated cards I haven't reviewed yet."
47
+ - "Why did that reel score 22×?"
48
+ - "Are there other cards using this same hook pattern?"
49
+ - "Export my hook library so I can look at it in a spreadsheet."
50
+
51
+ ## Composing filters — the move to reach for first
52
+
53
+ Every filter narrows the same query, so translate the member's sentence into flags rather than pulling a big list and squinting at it. The spec's own example:
54
+
55
+ > "10×+, on-screen hooks, Spanish, under 14 days"
56
+
57
+ ```bash
58
+ npx @aicopycoders/exodus hooks list --min-score 10 --has onscreen --language es --days 14
59
+ ```
60
+
61
+ More of the same shape:
62
+
63
+ ```bash
64
+ # Structural winners nobody has run yet
65
+ npx @aicopycoders/exodus hooks list --status cross-validated --lifecycle new
66
+
67
+ # The highest-opportunity find: proven elsewhere, absent in your language
68
+ npx @aicopycoders/exodus hooks list --status virgin-market --limit 25
69
+
70
+ # One creator's breakouts, newest first
71
+ npx @aicopycoders/exodus hooks list --creator someonehandle --min-score 5
72
+
73
+ # What the feed spotter caught this week vs. what seeds brought in
74
+ npx @aicopycoders/exodus hooks list --via spotter --days 7
75
+ npx @aicopycoders/exodus hooks list --via seed --days 7
76
+ ```
77
+
78
+ Default limit is 50, max 200. For a real corpus sweep use `export`, which cursor-walks the whole filtered set instead of stopping at the cap.
79
+
80
+ ## Worked example — "what do my 15×+ hooks have in common?"
81
+
82
+ 1. **Pull the slice as data, not a table.** You're going to analyze it, so ask for JSON:
83
+
84
+ ```bash
85
+ npx @aicopycoders/exodus hooks list --min-score 15 --json
86
+ ```
87
+
88
+ 2. **Read the fields that carry the pattern.** `hookPattern` (the blanks-exposed hook) and `formatSkeleton` (the shape of the video) are what group; the verbatim `hookSpoken` / `hookOnScreen` / `hookCaption` are the evidence you quote back. Also worth counting: which `lanes` fired, `validationStatus`, `language`, and which hook type is filled.
89
+
90
+ 3. **Group and summarize in plain words.** Name the two or three recurring shapes, say how many cards each covers, quote one real hook per shape, and note what the numbers say ("11 of your 14 cards above 15× lead with an on-screen hook; 9 are personal-timeline claims — 'I did {thing} for {duration}'").
91
+
92
+ 4. **Go deeper on the strongest shape.** Take the best card and ask the library whether it's a one-off:
93
+
94
+ ```bash
95
+ npx @aicopycoders/exodus hooks find-similar <ref>
96
+ npx @aicopycoders/exodus hooks explain-score <ref>
97
+ ```
98
+
99
+ 5. **Hand back a move, not just a report.** The strategist call (which shape to write from, which awareness level) belongs to `exodus-strategist`; the writing belongs to `exodus-write` / `exodus-genesis`.
100
+
101
+ ## Going deeper on one card
102
+
103
+ - **`show <ref>`** — the full card: every hook and its type, pattern, format skeleton, scores with evidence, validation status, lifecycle, language/market, relevance tag, transcript, source link.
104
+ - **`find-similar <ref>`** — other cards in this brand sharing the same canonical hook pattern. This is how you tell a repeatable structure from a lucky post.
105
+ - **`explain-score <ref>`** — the receipts: the evidence sentence saying why the card scored what it scored, the baseline numbers behind it, and each detection lane's own reason ("1.2M views clears the absolute bar"). Reach for this any time a member doubts a number, and quote the sentence rather than paraphrasing it.
106
+
107
+ ## Honesty rules — never paper over a gap
108
+
109
+ The cards are built so a gap always announces itself. Relay the gap; do not fill it in.
110
+
111
+ - **No score is a real answer.** A card with `baselineInsufficient` has **no viral score** — the creator's baseline was too thin to divide by. Its `scoreEvidence` sentence says exactly that. Never estimate, average, or invent a multiple for it, and don't quietly drop it from a summary — say "3 cards can't be scored yet (too few posts to compare against)".
112
+ - **Missing hooks have a reason.** `extractStatus` of `partial`, `failed`, or `skipped` means some or all hook text is absent, and `extractEvidence` says why. If a slice looks thin, check extraction before concluding the hooks aren't there.
113
+ - **"unproven" is not a defect.** Every card lands as `unproven` at capture — one post, one creator, no corroboration run yet. It means "not checked", not "checked and found weak". Only call something weak on evidence.
114
+ - **Views decide; likes and comments don't.** They're recorded as evidence, never as score. Rank on `viralScore`, never on engagement.
115
+ - **Relevance ranks, it never gates.** A low relevance tag with a huge score is a structure to steal, not a card to discard.
116
+
117
+ ## Output — table, JSON, or CSV
118
+
119
+ - **Table (default)** — when the member just wants to look at what's there. Don't paste 50 rows back; surface the handful that answers the question and name them.
120
+ - **`--json`** — whenever *you* are doing the analysis: grouping, counting, comparing hook types, chasing a pattern. Parse it, then answer in plain language.
121
+ - **`export --csv`** — when they ask for a spreadsheet or want to work outside Claude. `export` walks the whole filtered corpus past the list cap; `--out <file>` writes it, otherwise it prints.
122
+
123
+ ```bash
124
+ npx @aicopycoders/exodus hooks export --csv --min-score 10 --days 30 --out hooks-30d.csv
125
+ ```
126
+
127
+ Same filters as `list`, so build the query with `list` first, eyeball that it's the right slice, then re-run it as an export.
128
+
129
+ ## Read-only — and not the Idea Bank
130
+
131
+ This family **only reads**. There is no promote, edit, kill, or re-score command here. Promoting a hook card (and moving its lifecycle along) is a **dashboard act** — if the member wants a card promoted, point them at the Scout page (Sources → Scout) on the dashboard rather than hunting for a flag that doesn't exist.
132
+
133
+ And keep the two objects straight: a **hook card** is a capture in the Scout library — every outlier the system found. An **Idea Bank card** is work-queue material the member is about to write from. Different object, different lifecycle, different command family. "What's in my idea bank?" / "write from idea G3" is `exodus idea …` — route that back through the `exodus` hub skill (or `exodus-write` for the writing flow), never through `exodus hooks`.
@@ -22,6 +22,7 @@ const OTHER_COMMANDS = new Set([
22
22
  "workflow",
23
23
  "session",
24
24
  "bank",
25
+ "hooks",
25
26
  ]);
26
27
  const HIDDEN_COMMANDS = new Set([
27
28
  "creative",
@@ -39,6 +40,7 @@ const EXAMPLES = [
39
40
  'exodus meme recommend --brief "grounding sheets reduce inflammation"',
40
41
  'exodus meme run --brief "grounding sheets reduce inflammation" --formats \'[...]\'',
41
42
  'exodus browse',
43
+ 'exodus hooks list --min-score 10',
42
44
  'exodus status --id <runId> --type genesis',
43
45
  ];
44
46
  function installedCommands() {
@@ -117,8 +117,8 @@ export async function checkWhoami() {
117
117
  return {
118
118
  ok: false,
119
119
  label: "Brand resolution",
120
- detail: "API key did not resolve to a workspace",
121
- fix: "ask an admin to mint a brand-scoped API key in Settings → Brands → API keys",
120
+ detail: "API key did not resolve to a brand",
121
+ fix: "pick or create a brand in the dashboard (brand picker, top right); if that's done, ask an admin to mint a brand-scoped API key in Settings → Brands → API keys",
122
122
  };
123
123
  }
124
124
  if (!d.foundationReady) {
@@ -410,8 +410,8 @@ export async function checkApiAndDrive() {
410
410
  : {
411
411
  ok: false,
412
412
  label: "Dashboard API key",
413
- detail: "no workspace found for this key",
414
- fix: "contact support — your workspace may not be set up",
413
+ detail: "no brand to work in — none selected, or the saved one is gone / not yours",
414
+ fix: "pick or create a brand in the dashboard (brand picker, top right), then run doctor again",
415
415
  };
416
416
  const driveResult = drive.connected
417
417
  ? {
@@ -0,0 +1,161 @@
1
+ import { type ApiResponse } from "../lib/client.js";
2
+ import { type Channel } from "../lib/channel.js";
3
+ export declare const helpText: string;
4
+ export declare const LANES: readonly ["relative", "absolute", "velocity", "evergreen"];
5
+ export declare const VALIDATION_STATUSES: readonly ["unproven", "corroborated", "cross-validated", "saturated", "virgin-market"];
6
+ export declare const LIFECYCLES: readonly ["new", "reviewed", "promoted", "tested", "killed"];
7
+ export declare const DISCOVERED_VIA: readonly ["paste", "seed", "expansion", "spotter"];
8
+ export declare const EXTRACT_STATUSES: readonly ["pending", "complete", "partial", "failed", "skipped"];
9
+ export declare const HAS_HOOKS: readonly ["spoken", "onscreen", "caption"];
10
+ export interface ApiHookLane {
11
+ lane?: string | null;
12
+ reason?: string | null;
13
+ }
14
+ export interface ApiHookCorroboration {
15
+ replicationCount?: number | null;
16
+ languageSpread?: number | string[] | null;
17
+ marketsSeen?: string[] | null;
18
+ checkedAt?: number | null;
19
+ }
20
+ export interface ApiHookGate {
21
+ verdict?: string | null;
22
+ reasoning?: string | null;
23
+ decidedAt?: number | null;
24
+ }
25
+ export interface ApiHookCard {
26
+ id?: string | null;
27
+ shortcode?: string | null;
28
+ sourceUrl?: string | null;
29
+ creatorHandle?: string | null;
30
+ caption?: string | null;
31
+ thumbnailUrl?: string | null;
32
+ publishedAt?: number | null;
33
+ capturedAt?: number | null;
34
+ viewCount?: number | null;
35
+ likeCount?: number | null;
36
+ commentCount?: number | null;
37
+ creatorTypicalViews?: number | null;
38
+ baselineSampleSize?: number | null;
39
+ baselineInsufficient?: boolean | null;
40
+ viralScore?: number | null;
41
+ scoreEvidence?: string | null;
42
+ lanes?: ApiHookLane[] | null;
43
+ hookSpoken?: string | null;
44
+ hookOnScreen?: string | null;
45
+ hookCaption?: string | null;
46
+ hookPattern?: string | null;
47
+ formatSkeleton?: string | null;
48
+ extractStatus?: string | null;
49
+ extractEvidence?: string | null;
50
+ validationStatus?: string | null;
51
+ corroboration?: ApiHookCorroboration | null;
52
+ language?: string | null;
53
+ market?: string | null;
54
+ relevanceTag?: string | number | null;
55
+ lifecycle?: string | null;
56
+ discoveredVia?: string | null;
57
+ gate?: ApiHookGate | null;
58
+ transcript?: string | null;
59
+ metricSnapshots?: unknown[] | null;
60
+ velocityPollCount?: number | null;
61
+ citedStudy?: string | null;
62
+ hookPatternKey?: string | null;
63
+ }
64
+ export interface HookListResponse {
65
+ cards?: ApiHookCard[];
66
+ cursor?: string | null;
67
+ isDone?: boolean;
68
+ }
69
+ export interface HookShowResponse {
70
+ card?: ApiHookCard;
71
+ }
72
+ export interface HookSimilarSubject {
73
+ id?: string | null;
74
+ shortcode?: string | null;
75
+ creatorHandle?: string | null;
76
+ hookPattern?: string | null;
77
+ viralScore?: number | null;
78
+ scoreEvidence?: string | null;
79
+ }
80
+ export interface HookSimilarResponse {
81
+ card?: HookSimilarSubject;
82
+ similar?: ApiHookCard[];
83
+ }
84
+ export declare const EXPORT_CAP = 5000;
85
+ export declare const EXPORT_PAGE_SIZE = 200;
86
+ export declare const LIST_SCAN_CAP = 2000;
87
+ export interface FlowResult {
88
+ code: number;
89
+ lines: string[];
90
+ warnings?: string[];
91
+ }
92
+ export interface HooksDeps {
93
+ get: (path: string) => Promise<ApiResponse<unknown>>;
94
+ writeFile: (path: string, content: string) => void;
95
+ channel: Channel;
96
+ now: () => number;
97
+ apiUrl: () => string;
98
+ }
99
+ export interface HookFilters {
100
+ minScore?: number;
101
+ lane?: string;
102
+ status?: string;
103
+ lifecycle?: string;
104
+ creator?: string;
105
+ language?: string;
106
+ market?: string;
107
+ via?: string;
108
+ sinceDays?: number;
109
+ extract?: string;
110
+ has?: string;
111
+ pattern?: string;
112
+ limit?: number;
113
+ }
114
+ export type ParseResult = {
115
+ ok: true;
116
+ filters: HookFilters;
117
+ } | {
118
+ ok: false;
119
+ message: string;
120
+ };
121
+ export declare function parseHookFilters(flags: Record<string, string | boolean>, maxLimit: number): ParseResult;
122
+ export declare function filterQuery(filters: HookFilters, extra?: {
123
+ limit?: number;
124
+ cursor?: string;
125
+ }): string;
126
+ export declare function formatScore(score: unknown): string;
127
+ export declare function shortAge(value: unknown, now: number): string;
128
+ export declare function laneCodes(lanes: ApiHookLane[] | null | undefined): string;
129
+ export declare function truncate(text: unknown, max: number): string;
130
+ export declare function formatCardTable(cards: ApiHookCard[], now: number): string;
131
+ export interface ListWalk {
132
+ cards: ApiHookCard[];
133
+ more: boolean;
134
+ scanCapped: boolean;
135
+ }
136
+ export declare function formatList(walk: ListWalk, now: number): string[];
137
+ export declare function formatShow(card: ApiHookCard, now: number): string;
138
+ export declare function formatExplainScore(card: ApiHookCard, now: number): string[];
139
+ export declare function explainScoreJson(card: ApiHookCard): Record<string, unknown>;
140
+ export declare const CSV_COLUMNS: readonly ["id", "shortcode", "sourceUrl", "creatorHandle", "viralScore", "scoreEvidence", "viewCount", "likeCount", "commentCount", "creatorTypicalViews", "baselineSampleSize", "baselineInsufficient", "validationStatus", "lanes", "hookSpoken", "hookOnScreen", "hookCaption", "hookPattern", "formatSkeleton", "extractStatus", "language", "market", "lifecycle", "discoveredVia", "capturedAt", "publishedAt", "replicationCount", "languageSpread"];
141
+ export declare function csvEscape(value: unknown): string;
142
+ export declare const CSV_RECORD_SEP = "\r\n";
143
+ export declare function toCsv(cards: ApiHookCard[]): string;
144
+ export declare function listFlow(filters: HookFilters, json: boolean, deps: HooksDeps): Promise<FlowResult>;
145
+ export declare function showFlow(ref: string, json: boolean, deps: HooksDeps): Promise<FlowResult>;
146
+ export declare function explainScoreFlow(ref: string, json: boolean, deps: HooksDeps): Promise<FlowResult>;
147
+ export declare function findSimilarFlow(ref: string, json: boolean, deps: HooksDeps): Promise<FlowResult>;
148
+ export interface ExportOptions {
149
+ format: "csv" | "json";
150
+ out?: string;
151
+ }
152
+ export declare function exportFlow(filters: HookFilters, options: ExportOptions, deps: HooksDeps): Promise<FlowResult>;
153
+ export declare function parsePositional(args?: string[]): string[];
154
+ export declare function resolveExportFormat(flags: Record<string, string | boolean>): {
155
+ ok: true;
156
+ format: "csv" | "json";
157
+ } | {
158
+ ok: false;
159
+ message: string;
160
+ };
161
+ export declare function run(flags: Record<string, string | boolean>): Promise<void>;
@@ -0,0 +1,815 @@
1
+ import fs from "node:fs";
2
+ import { apiGet, getApiUrl } from "../lib/client.js";
3
+ import { formatApiError } from "../lib/format.js";
4
+ import { auth401Hint } from "../lib/backend-hint.js";
5
+ import { getChannel } from "../lib/channel.js";
6
+ import { missingRouteLine } from "../lib/route-support.js";
7
+ export const helpText = `
8
+ exodus hooks — browse the Scout hook library (read-only)
9
+
10
+ Every organic reel the Scout captured for this brand becomes a HOOK CARD: the
11
+ outlier score with the sentence that justifies it, the three hooks (spoken,
12
+ on-screen, caption), the pattern behind them, and how far the pattern has been
13
+ validated. This family only READS that library — nothing here changes a card.
14
+ Promoting a hook into a brief or an Idea Bank card is a dashboard act, on
15
+ purpose: a hook card and an Idea Bank card are different objects.
16
+
17
+ Usage:
18
+ exodus hooks list [filters] [--limit n] [--json]
19
+ exodus hooks show <ref> [--json]
20
+ exodus hooks find-similar <ref> [--json]
21
+ exodus hooks explain-score <ref> [--json]
22
+ exodus hooks export --csv|--json [filters] [--limit n] [--out <file>]
23
+
24
+ <ref> is a card id, an Instagram shortcode, or a full Instagram URL.
25
+
26
+ Filters (list + export):
27
+ --min-score <n> Only cards scoring at least n× the creator's own median
28
+ --lane <x> relative | absolute | velocity | evergreen
29
+ --status <x> unproven | corroborated | cross-validated | saturated | virgin-market
30
+ --lifecycle <x> new | reviewed | promoted | tested | killed
31
+ --creator <handle> One creator's cards (with or without the leading @)
32
+ --language <xx> Spoken/caption language code, e.g. en, es
33
+ --market <xx> Market code, e.g. US, MX
34
+ --via <x> paste | seed | expansion | spotter
35
+ --days <n> Captured within the last n days
36
+ --extract <x> pending | complete | partial | failed | skipped
37
+ --has <x> spoken | onscreen | caption — cards with that hook extracted
38
+ --pattern "<text>" Hook patterns containing this text
39
+
40
+ Other flags:
41
+ --limit <n> list: how many cards to show, 1–200 (default 50). A
42
+ filtered list keeps paging until it has that many, or
43
+ until it has searched the newest ~2000 cards — older
44
+ ones live behind \`export\`.
45
+ export: most rows to write, 1–5000 (default 5000).
46
+ --json Machine-readable JSON instead of the human view
47
+ --csv (export) Comma-separated rows, RFC-4180 quoted
48
+ --out <file> (export) Write to a file instead of stdout
49
+
50
+ Examples:
51
+ exodus hooks list --min-score 10 --lane velocity
52
+ exodus hooks list --has onscreen --language es --days 14
53
+ exodus hooks show C9xAbCdEfGh
54
+ exodus hooks explain-score https://www.instagram.com/reel/C9xAbCdEfGh/
55
+ exodus hooks find-similar C9xAbCdEfGh
56
+ exodus hooks export --csv --min-score 5 --out hooks.csv
57
+ `.trim();
58
+ export const LANES = ["relative", "absolute", "velocity", "evergreen"];
59
+ export const VALIDATION_STATUSES = [
60
+ "unproven",
61
+ "corroborated",
62
+ "cross-validated",
63
+ "saturated",
64
+ "virgin-market",
65
+ ];
66
+ export const LIFECYCLES = ["new", "reviewed", "promoted", "tested", "killed"];
67
+ export const DISCOVERED_VIA = ["paste", "seed", "expansion", "spotter"];
68
+ export const EXTRACT_STATUSES = ["pending", "complete", "partial", "failed", "skipped"];
69
+ export const HAS_HOOKS = ["spoken", "onscreen", "caption"];
70
+ const LIST_PATH = "/api/v2/hooks";
71
+ const SHOW_PATH = "/api/v2/hooks/show";
72
+ const SIMILAR_PATH = "/api/v2/hooks/find-similar";
73
+ export const EXPORT_CAP = 5000;
74
+ export const EXPORT_PAGE_SIZE = 200;
75
+ export const LIST_SCAN_CAP = 2000;
76
+ const VALUE_FLAGS = new Set([
77
+ "min-score",
78
+ "lane",
79
+ "status",
80
+ "lifecycle",
81
+ "creator",
82
+ "language",
83
+ "market",
84
+ "via",
85
+ "days",
86
+ "extract",
87
+ "has",
88
+ "pattern",
89
+ "limit",
90
+ "out",
91
+ ]);
92
+ const defaultDeps = {
93
+ get: (path) => apiGet(path),
94
+ writeFile: (path, content) => fs.writeFileSync(path, content, "utf-8"),
95
+ channel: getChannel(),
96
+ now: () => Date.now(),
97
+ apiUrl: () => getApiUrl(),
98
+ };
99
+ function str(flag) {
100
+ return typeof flag === "string" && flag.trim() ? flag.trim() : undefined;
101
+ }
102
+ function oneOf(flags, name, allowed) {
103
+ const raw = flags[name];
104
+ if (raw === undefined)
105
+ return { ok: true };
106
+ if (typeof raw !== "string" || raw.trim() === "") {
107
+ return { ok: false, message: `--${name} needs a value — one of: ${allowed.join(", ")}` };
108
+ }
109
+ const value = raw.trim();
110
+ if (!allowed.includes(value)) {
111
+ return {
112
+ ok: false,
113
+ message: `--${name} must be one of: ${allowed.join(", ")} (got "${value}")`,
114
+ };
115
+ }
116
+ return { ok: true, value };
117
+ }
118
+ function positiveNumber(flags, name) {
119
+ const raw = flags[name];
120
+ if (raw === undefined)
121
+ return { ok: true };
122
+ if (typeof raw !== "string" || raw.trim() === "") {
123
+ return { ok: false, message: `--${name} must be a number (got "${String(raw)}")` };
124
+ }
125
+ const n = Number(raw);
126
+ if (Number.isNaN(n) || n < 0) {
127
+ return { ok: false, message: `--${name} must be a number 0 or greater (got "${raw}")` };
128
+ }
129
+ return { ok: true, value: n };
130
+ }
131
+ export function parseHookFilters(flags, maxLimit) {
132
+ const filters = {};
133
+ const minScore = positiveNumber(flags, "min-score");
134
+ if (!minScore.ok)
135
+ return { ok: false, message: minScore.message };
136
+ if (minScore.value !== undefined)
137
+ filters.minScore = minScore.value;
138
+ const days = positiveNumber(flags, "days");
139
+ if (!days.ok)
140
+ return { ok: false, message: days.message };
141
+ if (days.value !== undefined) {
142
+ if (!Number.isInteger(days.value) || days.value < 1) {
143
+ return { ok: false, message: `--days must be a whole number of days, 1 or more` };
144
+ }
145
+ filters.sinceDays = days.value;
146
+ }
147
+ const enums = [
148
+ ["lane", "lane", LANES],
149
+ ["status", "status", VALIDATION_STATUSES],
150
+ ["lifecycle", "lifecycle", LIFECYCLES],
151
+ ["via", "via", DISCOVERED_VIA],
152
+ ["extract", "extract", EXTRACT_STATUSES],
153
+ ["has", "has", HAS_HOOKS],
154
+ ];
155
+ for (const [key, flagName, allowed] of enums) {
156
+ const parsed = oneOf(flags, flagName, allowed);
157
+ if (!parsed.ok)
158
+ return { ok: false, message: parsed.message };
159
+ if (parsed.value !== undefined) {
160
+ filters[key] = parsed.value;
161
+ }
162
+ }
163
+ const creator = str(flags["creator"]);
164
+ if (creator)
165
+ filters.creator = creator.replace(/^@+/, "");
166
+ const language = str(flags["language"]);
167
+ if (language)
168
+ filters.language = language;
169
+ const market = str(flags["market"]);
170
+ if (market)
171
+ filters.market = market;
172
+ const pattern = str(flags["pattern"]);
173
+ if (pattern)
174
+ filters.pattern = pattern;
175
+ const limitRaw = flags["limit"];
176
+ if (limitRaw !== undefined) {
177
+ if (typeof limitRaw !== "string" || limitRaw.trim() === "") {
178
+ return { ok: false, message: `--limit must be a whole number 1–${maxLimit}` };
179
+ }
180
+ const n = Number(limitRaw);
181
+ if (!Number.isInteger(n) || n < 1 || n > maxLimit) {
182
+ return { ok: false, message: `--limit must be a whole number 1–${maxLimit} (got "${limitRaw}")` };
183
+ }
184
+ filters.limit = n;
185
+ }
186
+ return { ok: true, filters };
187
+ }
188
+ export function filterQuery(filters, extra = {}) {
189
+ const params = new URLSearchParams();
190
+ if (extra.limit !== undefined)
191
+ params.set("limit", String(extra.limit));
192
+ if (extra.cursor)
193
+ params.set("cursor", extra.cursor);
194
+ if (filters.minScore !== undefined)
195
+ params.set("minScore", String(filters.minScore));
196
+ if (filters.lane)
197
+ params.set("lane", filters.lane);
198
+ if (filters.status)
199
+ params.set("status", filters.status);
200
+ if (filters.lifecycle)
201
+ params.set("lifecycle", filters.lifecycle);
202
+ if (filters.creator)
203
+ params.set("creator", filters.creator);
204
+ if (filters.language)
205
+ params.set("language", filters.language);
206
+ if (filters.market)
207
+ params.set("market", filters.market);
208
+ if (filters.via)
209
+ params.set("via", filters.via);
210
+ if (filters.sinceDays !== undefined)
211
+ params.set("sinceDays", String(filters.sinceDays));
212
+ if (filters.extract)
213
+ params.set("extract", filters.extract);
214
+ if (filters.has)
215
+ params.set("has", filters.has);
216
+ if (filters.pattern)
217
+ params.set("pattern", filters.pattern);
218
+ const qs = params.toString();
219
+ return qs ? `?${qs}` : "";
220
+ }
221
+ function table(headers, rows) {
222
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
223
+ const fmt = (row) => row.map((cell, i) => (cell ?? "").padEnd(widths[i])).join(" ").trimEnd();
224
+ return [
225
+ fmt(headers),
226
+ fmt(headers.map((h) => "-".repeat(h.length))),
227
+ ...rows.map(fmt),
228
+ ].join("\n");
229
+ }
230
+ export function formatScore(score) {
231
+ if (typeof score !== "number" || !Number.isFinite(score))
232
+ return "—";
233
+ return `${score.toFixed(1)}×`;
234
+ }
235
+ function formatCount(n) {
236
+ return typeof n === "number" && Number.isFinite(n) ? n.toLocaleString("en-US") : "—";
237
+ }
238
+ export function shortAge(value, now) {
239
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
240
+ return "—";
241
+ const diff = now - value;
242
+ if (diff < 0)
243
+ return "now";
244
+ const min = Math.floor(diff / 60000);
245
+ if (min < 60)
246
+ return min < 1 ? "now" : `${min}m`;
247
+ const hr = Math.floor(min / 60);
248
+ if (hr < 24)
249
+ return `${hr}h`;
250
+ const day = Math.floor(hr / 24);
251
+ if (day < 7)
252
+ return `${day}d`;
253
+ const wk = Math.floor(day / 7);
254
+ if (wk < 5)
255
+ return `${wk}w`;
256
+ const mo = Math.floor(day / 30);
257
+ if (mo < 12)
258
+ return `${mo}mo`;
259
+ return `${Math.floor(day / 365)}y`;
260
+ }
261
+ function isoOrEmpty(ms) {
262
+ if (typeof ms !== "number" || !Number.isFinite(ms) || ms <= 0)
263
+ return "";
264
+ const d = new Date(ms);
265
+ return Number.isNaN(d.getTime()) ? "" : d.toISOString();
266
+ }
267
+ function dateOrDash(ms) {
268
+ const iso = isoOrEmpty(ms);
269
+ return iso ? iso.slice(0, 10) : "—";
270
+ }
271
+ const LANE_SHORT = {
272
+ relative: "rel",
273
+ absolute: "abs",
274
+ velocity: "vel",
275
+ evergreen: "evg",
276
+ };
277
+ export function laneCodes(lanes) {
278
+ if (!Array.isArray(lanes) || lanes.length === 0)
279
+ return "—";
280
+ const codes = lanes
281
+ .map((l) => (typeof l?.lane === "string" ? (LANE_SHORT[l.lane] ?? l.lane) : ""))
282
+ .filter(Boolean);
283
+ return codes.length > 0 ? codes.join(",") : "—";
284
+ }
285
+ export function truncate(text, max) {
286
+ if (typeof text !== "string")
287
+ return "";
288
+ const flat = text.replace(/\s+/g, " ").trim();
289
+ if (flat.length <= max)
290
+ return flat;
291
+ const cut = flat.slice(0, max - 1);
292
+ const lastSpace = cut.lastIndexOf(" ");
293
+ const body = lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut;
294
+ return `${body}…`;
295
+ }
296
+ const PATTERN_WIDTH = 44;
297
+ export function formatCardTable(cards, now) {
298
+ return table(["score", "creator", "hook pattern", "validation", "lanes", "captured"], cards.map((c) => [
299
+ formatScore(c.viralScore),
300
+ c.creatorHandle ? `@${c.creatorHandle.replace(/^@+/, "")}` : "—",
301
+ truncate(c.hookPattern, PATTERN_WIDTH) || "(not extracted)",
302
+ c.validationStatus ?? "—",
303
+ laneCodes(c.lanes),
304
+ shortAge(c.capturedAt, now),
305
+ ]));
306
+ }
307
+ const EMPTY_LIST_LINES = [
308
+ "No hook cards match those filters.",
309
+ "Loosen a filter, or see everything: exodus hooks list",
310
+ ];
311
+ const NARROW_OR_EXPORT = "Narrow it with --days or --creator, or pull everything: exodus hooks export --csv --out hooks.csv";
312
+ export function formatList(walk, now) {
313
+ const { cards, more, scanCapped } = walk;
314
+ if (cards.length === 0) {
315
+ if (scanCapped) {
316
+ return [
317
+ `Nothing matched in the newest ~${LIST_SCAN_CAP} cards; older cards remain unscanned.`,
318
+ NARROW_OR_EXPORT,
319
+ ];
320
+ }
321
+ return [...EMPTY_LIST_LINES];
322
+ }
323
+ const count = `${cards.length} card${cards.length === 1 ? "" : "s"}`;
324
+ const lines = [formatCardTable(cards, now), ""];
325
+ if (scanCapped) {
326
+ lines.push(`${count}. Searched the newest ~${LIST_SCAN_CAP} cards; older cards remain unscanned.`);
327
+ lines.push(NARROW_OR_EXPORT);
328
+ }
329
+ else if (more) {
330
+ lines.push(`${count} — more available; raise --limit (max 200) or use \`exodus hooks export\`.`);
331
+ }
332
+ else {
333
+ lines.push(`${count}.`);
334
+ }
335
+ return lines;
336
+ }
337
+ function labelledHook(label, value) {
338
+ if (typeof value === "string" && value.trim()) {
339
+ return ` ${label.padEnd(11)}${value.trim()}`;
340
+ }
341
+ return ` ${label.padEnd(11)}(none extracted)`;
342
+ }
343
+ function corroborationLines(c, now) {
344
+ if (!c)
345
+ return [" (no corroboration run yet)"];
346
+ const lines = [];
347
+ lines.push(` replications: ${typeof c.replicationCount === "number" ? c.replicationCount : "—"}`);
348
+ const spread = Array.isArray(c.languageSpread)
349
+ ? c.languageSpread.join(", ")
350
+ : typeof c.languageSpread === "number"
351
+ ? String(c.languageSpread)
352
+ : "—";
353
+ lines.push(` languages: ${spread}`);
354
+ lines.push(` markets: ${Array.isArray(c.marketsSeen) && c.marketsSeen.length > 0 ? c.marketsSeen.join(", ") : "—"}`);
355
+ lines.push(` checked: ${typeof c.checkedAt === "number" ? shortAge(c.checkedAt, now) + " ago" : "never"}`);
356
+ return lines;
357
+ }
358
+ export function formatShow(card, now) {
359
+ const lines = [];
360
+ const handle = card.creatorHandle ? `@${card.creatorHandle.replace(/^@+/, "")}` : "(unknown creator)";
361
+ lines.push(`Hook card — ${handle}`);
362
+ if (card.sourceUrl)
363
+ lines.push(`url: ${card.sourceUrl}`);
364
+ lines.push(`views: ${formatCount(card.viewCount)} (likes ${formatCount(card.likeCount)} · comments ${formatCount(card.commentCount)})`);
365
+ lines.push(`score: ${formatScore(card.viralScore)}`);
366
+ if (card.scoreEvidence)
367
+ lines.push(` ${card.scoreEvidence}`);
368
+ lines.push(`published: ${dateOrDash(card.publishedAt)} · captured ${shortAge(card.capturedAt, now)} ago`);
369
+ lines.push("");
370
+ lines.push("Hooks");
371
+ lines.push(labelledHook("spoken:", card.hookSpoken));
372
+ lines.push(labelledHook("on-screen:", card.hookOnScreen));
373
+ lines.push(labelledHook("caption:", card.hookCaption));
374
+ lines.push("");
375
+ lines.push("Pattern");
376
+ lines.push(` pattern: ${card.hookPattern ?? "(not extracted yet)"}`);
377
+ lines.push(` skeleton: ${card.formatSkeleton ?? "(not extracted yet)"}`);
378
+ lines.push("");
379
+ lines.push("Extraction");
380
+ lines.push(` status: ${card.extractStatus ?? "unknown"}`);
381
+ lines.push(` evidence: ${card.extractEvidence ?? "(none recorded)"}`);
382
+ lines.push("");
383
+ lines.push(`Validation — ${card.validationStatus ?? "unknown"}`);
384
+ lines.push(...corroborationLines(card.corroboration, now));
385
+ lines.push("");
386
+ lines.push("Lanes");
387
+ if (Array.isArray(card.lanes) && card.lanes.length > 0) {
388
+ for (const lane of card.lanes) {
389
+ lines.push(` ${lane.lane ?? "?"} — ${lane.reason ?? "(no reason recorded)"}`);
390
+ }
391
+ }
392
+ else {
393
+ lines.push(" (no lane qualified this card)");
394
+ }
395
+ lines.push("");
396
+ lines.push("Gate");
397
+ if (card.gate && (card.gate.verdict || card.gate.reasoning)) {
398
+ lines.push(` verdict: ${card.gate.verdict ?? "—"}`);
399
+ lines.push(` reasoning: ${card.gate.reasoning ?? "—"}`);
400
+ if (typeof card.gate.decidedAt === "number") {
401
+ lines.push(` decided: ${shortAge(card.gate.decidedAt, now)} ago`);
402
+ }
403
+ }
404
+ else {
405
+ lines.push(" (no gate decision recorded)");
406
+ }
407
+ lines.push("");
408
+ lines.push("Provenance");
409
+ lines.push(` lifecycle: ${card.lifecycle ?? "—"}`);
410
+ lines.push(` discovered: ${card.discoveredVia ?? "—"}`);
411
+ lines.push(` language: ${card.language ?? "—"} · market ${card.market ?? "—"} · tag ${card.relevanceTag ?? "—"}`);
412
+ if (typeof card.velocityPollCount === "number") {
413
+ lines.push(` velocity: ${card.velocityPollCount} poll(s) recorded`);
414
+ }
415
+ if (card.citedStudy)
416
+ lines.push(` cited: ${card.citedStudy}`);
417
+ lines.push(` id: ${card.id ?? "—"} · shortcode ${card.shortcode ?? "—"}`);
418
+ if (card.hookPatternKey)
419
+ lines.push(` patternKey: ${card.hookPatternKey}`);
420
+ lines.push("");
421
+ lines.push("Transcript");
422
+ if (typeof card.transcript === "string" && card.transcript.trim()) {
423
+ for (const line of card.transcript.trim().split("\n"))
424
+ lines.push(` ${line}`);
425
+ }
426
+ else {
427
+ lines.push(" (no transcript captured)");
428
+ }
429
+ return lines.join("\n");
430
+ }
431
+ export function formatExplainScore(card, now) {
432
+ const lines = [];
433
+ lines.push(card.scoreEvidence?.trim() || "(this card carries no score evidence sentence)");
434
+ lines.push("");
435
+ lines.push(`score: ${formatScore(card.viralScore)}`);
436
+ lines.push(`views: ${formatCount(card.viewCount)}`);
437
+ lines.push(`creator's typical: ${formatCount(card.creatorTypicalViews)}`);
438
+ lines.push(`baseline sample: ${typeof card.baselineSampleSize === "number" ? `${card.baselineSampleSize} post(s)` : "—"}`);
439
+ if (card.baselineInsufficient) {
440
+ lines.push(" ⚠ thin baseline — too few posts to trust this multiple yet.");
441
+ }
442
+ lines.push("");
443
+ lines.push("Lanes");
444
+ if (Array.isArray(card.lanes) && card.lanes.length > 0) {
445
+ for (const lane of card.lanes) {
446
+ lines.push(` ${lane.lane ?? "?"} — ${lane.reason ?? "(no reason recorded)"}`);
447
+ }
448
+ }
449
+ else {
450
+ lines.push(" (no lane qualified this card)");
451
+ }
452
+ if (card.gate?.reasoning) {
453
+ lines.push("");
454
+ lines.push("Gate");
455
+ lines.push(` ${card.gate.verdict ? `${card.gate.verdict} — ` : ""}${card.gate.reasoning}`);
456
+ if (typeof card.gate.decidedAt === "number") {
457
+ lines.push(` decided ${shortAge(card.gate.decidedAt, now)} ago`);
458
+ }
459
+ }
460
+ return lines;
461
+ }
462
+ export function explainScoreJson(card) {
463
+ return {
464
+ id: card.id,
465
+ shortcode: card.shortcode,
466
+ viralScore: card.viralScore,
467
+ scoreEvidence: card.scoreEvidence,
468
+ viewCount: card.viewCount,
469
+ creatorTypicalViews: card.creatorTypicalViews,
470
+ baselineSampleSize: card.baselineSampleSize,
471
+ baselineInsufficient: card.baselineInsufficient,
472
+ lanes: card.lanes ?? [],
473
+ gate: card.gate,
474
+ };
475
+ }
476
+ export const CSV_COLUMNS = [
477
+ "id",
478
+ "shortcode",
479
+ "sourceUrl",
480
+ "creatorHandle",
481
+ "viralScore",
482
+ "scoreEvidence",
483
+ "viewCount",
484
+ "likeCount",
485
+ "commentCount",
486
+ "creatorTypicalViews",
487
+ "baselineSampleSize",
488
+ "baselineInsufficient",
489
+ "validationStatus",
490
+ "lanes",
491
+ "hookSpoken",
492
+ "hookOnScreen",
493
+ "hookCaption",
494
+ "hookPattern",
495
+ "formatSkeleton",
496
+ "extractStatus",
497
+ "language",
498
+ "market",
499
+ "lifecycle",
500
+ "discoveredVia",
501
+ "capturedAt",
502
+ "publishedAt",
503
+ "replicationCount",
504
+ "languageSpread",
505
+ ];
506
+ const CSV_FORMULA_LEAD = /^[=+\-@\t\r]/;
507
+ export function csvEscape(value) {
508
+ if (value === undefined || value === null)
509
+ return "";
510
+ const s = typeof value === "string" && CSV_FORMULA_LEAD.test(value) ? `'${value}` : String(value);
511
+ if (/[",\r\n]/.test(s))
512
+ return `"${s.replace(/"/g, '""')}"`;
513
+ return s;
514
+ }
515
+ function csvCell(card, column) {
516
+ switch (column) {
517
+ case "lanes":
518
+ return Array.isArray(card.lanes)
519
+ ? card.lanes.map((l) => l?.lane ?? "").filter(Boolean).join("|")
520
+ : "";
521
+ case "capturedAt":
522
+ return isoOrEmpty(card.capturedAt);
523
+ case "publishedAt":
524
+ return isoOrEmpty(card.publishedAt);
525
+ case "replicationCount":
526
+ return card.corroboration?.replicationCount;
527
+ case "languageSpread": {
528
+ const spread = card.corroboration?.languageSpread;
529
+ return Array.isArray(spread) ? spread.join("|") : spread;
530
+ }
531
+ default:
532
+ return card[column];
533
+ }
534
+ }
535
+ export const CSV_RECORD_SEP = "\r\n";
536
+ export function toCsv(cards) {
537
+ const rows = [CSV_COLUMNS.join(",")];
538
+ for (const card of cards) {
539
+ rows.push(CSV_COLUMNS.map((col) => csvEscape(csvCell(card, col))).join(","));
540
+ }
541
+ return rows.join(CSV_RECORD_SEP);
542
+ }
543
+ function asErrorResult(res, json, deps) {
544
+ if (json) {
545
+ return { code: 1, lines: [JSON.stringify({ ok: false, status: res.status, data: res.data })] };
546
+ }
547
+ const lines = [formatApiError(res)];
548
+ if (res.status === 401 || res.status === 403) {
549
+ lines.push("");
550
+ lines.push(auth401Hint(deps.apiUrl()));
551
+ }
552
+ else if (res.status === 400 && /workspace|brand/i.test(lines[0])) {
553
+ lines.push("No active brand. Pick one first: exodus brand use <slug>");
554
+ }
555
+ return { code: 1, lines };
556
+ }
557
+ function notFoundResult(ref, res, json) {
558
+ if (json) {
559
+ return { code: 1, lines: [JSON.stringify({ ok: false, status: res.status, data: res.data })] };
560
+ }
561
+ const server = formatApiError(res);
562
+ const lines = [`No hook card matches "${ref}".`];
563
+ if (server && server !== `HTTP ${res.status}`)
564
+ lines.push(server);
565
+ lines.push("Give a card id, an Instagram shortcode, or the reel URL — or browse: exodus hooks list");
566
+ return { code: 1, lines };
567
+ }
568
+ function errorFor(res, verb, json, deps, ref) {
569
+ if (res.ok)
570
+ return undefined;
571
+ const unsupported = missingRouteLine(res, verb, deps.channel);
572
+ if (unsupported)
573
+ return { code: 1, lines: [unsupported] };
574
+ if (res.status === 404 && ref !== undefined)
575
+ return notFoundResult(ref, res, json);
576
+ return asErrorResult(res, json, deps);
577
+ }
578
+ export async function listFlow(filters, json, deps) {
579
+ const limit = filters.limit ?? 50;
580
+ const cards = [];
581
+ let cursor;
582
+ let requested = 0;
583
+ let more = false;
584
+ let scanCapped = false;
585
+ let firstPage = true;
586
+ for (;;) {
587
+ const scanRemaining = LIST_SCAN_CAP - requested;
588
+ if (scanRemaining <= 0) {
589
+ scanCapped = true;
590
+ break;
591
+ }
592
+ const pageSize = Math.min(firstPage ? limit : EXPORT_PAGE_SIZE, scanRemaining);
593
+ firstPage = false;
594
+ requested += pageSize;
595
+ const res = await deps.get(`${LIST_PATH}${filterQuery(filters, { limit: pageSize, cursor })}`);
596
+ const err = errorFor(res, "hooks list", json, deps);
597
+ if (err)
598
+ return err;
599
+ const data = (res.data ?? {});
600
+ const page = Array.isArray(data.cards) ? data.cards : [];
601
+ let dropped = 0;
602
+ for (const card of page) {
603
+ if (cards.length >= limit) {
604
+ dropped++;
605
+ continue;
606
+ }
607
+ cards.push(card);
608
+ }
609
+ const exhausted = data.isDone !== false;
610
+ const next = data.cursor ?? undefined;
611
+ if (cards.length >= limit) {
612
+ more = dropped > 0 || (!exhausted && !!next);
613
+ break;
614
+ }
615
+ if (exhausted)
616
+ break;
617
+ if (!next)
618
+ break;
619
+ cursor = next;
620
+ }
621
+ if (json)
622
+ return { code: 0, lines: [JSON.stringify(cards)] };
623
+ return { code: 0, lines: formatList({ cards, more, scanCapped }, deps.now()) };
624
+ }
625
+ export async function showFlow(ref, json, deps) {
626
+ const res = await deps.get(`${SHOW_PATH}?ref=${encodeURIComponent(ref)}`);
627
+ const err = errorFor(res, "hooks show", json, deps, ref);
628
+ if (err)
629
+ return err;
630
+ const card = (res.data ?? {}).card;
631
+ if (!card)
632
+ return { code: 1, lines: [`No hook card matches "${ref}".`] };
633
+ if (json)
634
+ return { code: 0, lines: [JSON.stringify(card)] };
635
+ return { code: 0, lines: [formatShow(card, deps.now())] };
636
+ }
637
+ export async function explainScoreFlow(ref, json, deps) {
638
+ const res = await deps.get(`${SHOW_PATH}?ref=${encodeURIComponent(ref)}`);
639
+ const err = errorFor(res, "hooks explain-score", json, deps, ref);
640
+ if (err)
641
+ return err;
642
+ const card = (res.data ?? {}).card;
643
+ if (!card)
644
+ return { code: 1, lines: [`No hook card matches "${ref}".`] };
645
+ if (json)
646
+ return { code: 0, lines: [JSON.stringify(explainScoreJson(card))] };
647
+ return { code: 0, lines: formatExplainScore(card, deps.now()) };
648
+ }
649
+ export async function findSimilarFlow(ref, json, deps) {
650
+ const res = await deps.get(`${SIMILAR_PATH}?ref=${encodeURIComponent(ref)}`);
651
+ const err = errorFor(res, "hooks find-similar", json, deps, ref);
652
+ if (err)
653
+ return err;
654
+ const data = (res.data ?? {});
655
+ if (json)
656
+ return { code: 0, lines: [JSON.stringify(data)] };
657
+ const subject = data.card ?? {};
658
+ const similar = Array.isArray(data.similar) ? data.similar : [];
659
+ const handle = subject.creatorHandle
660
+ ? `@${subject.creatorHandle.replace(/^@+/, "")}`
661
+ : "(unknown creator)";
662
+ const lines = [];
663
+ lines.push(`Subject — ${handle} ${formatScore(subject.viralScore)}`);
664
+ lines.push(`pattern: ${subject.hookPattern ?? "(not extracted yet)"}`);
665
+ if (subject.scoreEvidence)
666
+ lines.push(` ${subject.scoreEvidence}`);
667
+ lines.push("");
668
+ if (similar.length === 0) {
669
+ lines.push("No other cards share this hook pattern.");
670
+ if (!subject.hookPattern) {
671
+ lines.push("This card has no extracted pattern yet, so there was nothing to match on — that's why the list is empty, not because the hook is one of a kind.");
672
+ lines.push("Check extraction: exodus hooks show " + ref);
673
+ }
674
+ return { code: 0, lines };
675
+ }
676
+ lines.push(formatCardTable(similar, deps.now()));
677
+ lines.push("");
678
+ lines.push(similar.length === 1
679
+ ? "1 card shares this pattern."
680
+ : `${similar.length} cards share this pattern.`);
681
+ return { code: 0, lines };
682
+ }
683
+ export async function exportFlow(filters, options, deps) {
684
+ const cap = Math.min(filters.limit ?? EXPORT_CAP, EXPORT_CAP);
685
+ const cards = [];
686
+ let cursor;
687
+ let hitCap = false;
688
+ let more = false;
689
+ for (;;) {
690
+ const pageSize = Math.min(EXPORT_PAGE_SIZE, cap - cards.length);
691
+ if (pageSize <= 0) {
692
+ hitCap = true;
693
+ break;
694
+ }
695
+ const res = await deps.get(`${LIST_PATH}${filterQuery(filters, { limit: pageSize, cursor })}`);
696
+ const err = errorFor(res, "hooks export", options.format === "json", deps);
697
+ if (err)
698
+ return err;
699
+ const data = (res.data ?? {});
700
+ const page = Array.isArray(data.cards) ? data.cards : [];
701
+ for (const card of page) {
702
+ if (cards.length >= cap) {
703
+ hitCap = true;
704
+ break;
705
+ }
706
+ cards.push(card);
707
+ }
708
+ if (hitCap) {
709
+ more = true;
710
+ break;
711
+ }
712
+ if (data.isDone !== false)
713
+ break;
714
+ const next = data.cursor ?? undefined;
715
+ if (!next)
716
+ break;
717
+ cursor = next;
718
+ }
719
+ const body = options.format === "csv" ? toCsv(cards) : JSON.stringify(cards, null, 2);
720
+ const capNote = hitCap || more
721
+ ? `Stopped at ${cards.length} cards (export cap ${cap}) — there are more in the library. Narrow the filters to get the rest.`
722
+ : undefined;
723
+ if (options.out) {
724
+ const terminator = options.format === "csv" ? CSV_RECORD_SEP : "\n";
725
+ deps.writeFile(options.out, `${body}${terminator}`);
726
+ return {
727
+ code: 0,
728
+ lines: [`Wrote ${cards.length} hook card${cards.length === 1 ? "" : "s"} to ${options.out}.`],
729
+ warnings: capNote ? [capNote] : undefined,
730
+ };
731
+ }
732
+ return { code: 0, lines: [body], warnings: capNote ? [capNote] : undefined };
733
+ }
734
+ export function parsePositional(args = process.argv.slice(3)) {
735
+ const out = [];
736
+ let i = 0;
737
+ while (i < args.length) {
738
+ const arg = args[i];
739
+ if (arg.startsWith("--")) {
740
+ const key = arg.slice(2).split("=", 1)[0] ?? "";
741
+ if (!arg.includes("=") && VALUE_FLAGS.has(key))
742
+ i += 2;
743
+ else
744
+ i++;
745
+ continue;
746
+ }
747
+ out.push(arg);
748
+ i++;
749
+ }
750
+ return out;
751
+ }
752
+ export function resolveExportFormat(flags) {
753
+ const csv = flags["csv"] === true;
754
+ const json = flags["json"] === true;
755
+ if (csv && json) {
756
+ return { ok: false, message: "pass either --csv or --json to export, not both." };
757
+ }
758
+ if (!csv && !json) {
759
+ return { ok: false, message: "export needs an output shape: pass --csv or --json." };
760
+ }
761
+ return { ok: true, format: csv ? "csv" : "json" };
762
+ }
763
+ function printResult(result) {
764
+ for (const line of result.lines)
765
+ console.log(line);
766
+ for (const warning of result.warnings ?? [])
767
+ console.error(warning);
768
+ if (result.code !== 0)
769
+ process.exit(result.code);
770
+ }
771
+ function usageError(message, usage) {
772
+ console.error(`Error: ${message}`);
773
+ console.log(`Usage: ${usage}`);
774
+ process.exit(1);
775
+ }
776
+ export async function run(flags) {
777
+ const positional = parsePositional();
778
+ const [sub, ...rest] = positional;
779
+ const json = flags["json"] === true;
780
+ if (!sub || sub === "help") {
781
+ console.log(helpText);
782
+ return;
783
+ }
784
+ if (sub === "list") {
785
+ const parsed = parseHookFilters(flags, 200);
786
+ if (!parsed.ok)
787
+ usageError(parsed.message, "exodus hooks list [filters] [--limit n] [--json]");
788
+ return printResult(await listFlow(parsed.filters, json, defaultDeps));
789
+ }
790
+ if (sub === "show" || sub === "explain-score" || sub === "find-similar") {
791
+ const ref = rest[0];
792
+ if (!ref)
793
+ usageError(`hooks ${sub} needs a card id, shortcode, or Instagram URL.`, `exodus hooks ${sub} <ref> [--json]`);
794
+ if (sub === "show")
795
+ return printResult(await showFlow(ref, json, defaultDeps));
796
+ if (sub === "explain-score")
797
+ return printResult(await explainScoreFlow(ref, json, defaultDeps));
798
+ return printResult(await findSimilarFlow(ref, json, defaultDeps));
799
+ }
800
+ if (sub === "export") {
801
+ const format = resolveExportFormat(flags);
802
+ if (!format.ok) {
803
+ usageError(format.message, "exodus hooks export --csv|--json [filters] [--out <file>]");
804
+ }
805
+ const parsed = parseHookFilters(flags, EXPORT_CAP);
806
+ if (!parsed.ok) {
807
+ usageError(parsed.message, "exodus hooks export --csv|--json [filters] [--out <file>]");
808
+ }
809
+ const out = typeof flags["out"] === "string" ? flags["out"] : undefined;
810
+ return printResult(await exportFlow(parsed.filters, { format: format.format, out }, defaultDeps));
811
+ }
812
+ console.error(`Unknown subcommand: "${sub}"\n`);
813
+ console.log(helpText);
814
+ process.exit(1);
815
+ }
@@ -27,6 +27,7 @@ const PROVIDER_LABEL = {
27
27
  imgflip: "Imgflip",
28
28
  scrapecreators: "ScrapeCreators",
29
29
  apify: "Apify",
30
+ browserbase: "Browserbase",
30
31
  };
31
32
  async function status() {
32
33
  let remote;
@@ -12,6 +12,7 @@ export const SERVER_RESOLVED_PROVIDERS = new Set([
12
12
  "imgflip",
13
13
  "scrapecreators",
14
14
  "apify",
15
+ "browserbase",
15
16
  ]);
16
17
  export async function fetchRemoteKeys() {
17
18
  const res = await apiGetDashboard("/api/settings/keys");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.8.1600",
3
+ "version": "2026.9.200",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",