@aicopycoders/exodus 2026.7.200-beta.4 → 2026.7.200-beta.5

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.
@@ -18,6 +18,7 @@ This skill ROUTES. Read what the user wants, name the destination skill, and han
18
18
  | Static image ads ("make images", "statics", "render this ad") | `exodus-image` (the image front door — it menus engines and confirms before firing) |
19
19
  | A specific image engine they already chose | `exodus-creative` (native / copy-derived / ref-match) or `exodus-template` (33 ad-type formats) |
20
20
  | Meme ads ("exodus, meme this offer", "make me meme ads") | `exodus-meme` |
21
+ | Run / chain / build / edit a saved workflow ("run my workflow", "what does this workflow need", "build a workflow that…", "list my workflows") | `exodus-workflow` |
21
22
  | Onboard a new brand / build the primer | `exodus-primer` (with winning ads) or `exodus-foundation` (no ads yet) |
22
23
  | List/switch/troubleshoot brands | `exodus-brand` |
23
24
  | See past runs / find an output | `exodus-browse` |
@@ -0,0 +1,173 @@
1
+ ---
2
+ name: exodus-workflow
3
+ description: Run, chain, build, and edit saved Exodus Workflows — the multi-node automations that wire Genesis bots, primers, briefs, and image nodes together on the dashboard canvas — from the CLI. Use it to list a brand's workflows, describe what a workflow needs before running it (inputs, per-workspace primer prerequisites, outputs), run one and collect its outputs, chain one workflow's output into another's input, browse the bot catalog, and author or update a workflow contract via export → edit → import. Only invoke when the user has explicitly invoked Exodus: they said "exodus" in the request ("exodus, run my launch workflow", "exodus, what does this workflow need", "exodus, build a workflow that turns a swipe into hooks then ads", "list my exodus workflows", "exodus, edit that workflow"), named this skill or /exodus-workflow, ran an `npx @aicopycoders/exodus workflow` command, or the `exodus` hub skill routed here. Never claim generic "workflow"/"automation" requests ("automate this", "build me a workflow") without Exodus context — in shared folders those may belong to the user's other tools; if the user did not say exodus, this skill is not for them. The bare word "Genesis" without "exodus" refers to the member's own Genesis API key and personal bot recipes, NOT to Exodus.
4
+ ---
5
+
6
+ ```operator-guide
7
+ Subcommands:
8
+ exodus workflow list [--json] List the brand's saved workflows
9
+ exodus workflow describe <workflowId|name> [--json] Inputs, prerequisites, outputs
10
+ exodus workflow bots [--category <cat>] [--slug <slug>] [--json] Bot catalog / one bot's spec
11
+ exodus workflow run <workflowId|name> [--input key=value ...] [--wait] [--json] Run it
12
+ exodus workflow status [--id <runId>] [--json] Poll a run / read its outputs
13
+ exodus workflow export <workflowId|name> [--out <file>] Dump the contract JSON
14
+ exodus workflow import <file> [--update <workflowId>] [--dry-run] [--json] Author/edit
15
+
16
+ Key flags:
17
+ --input key=value Repeatable run input; the value may contain "="
18
+ --input key=@path Load the value from a file (path resolved from the cwd);
19
+ use key=@@text to keep a leading "@" as a literal character
20
+ --wait Poll until the run reaches a terminal status, then print outputs
21
+ --id <runId> Run id for `status` detail
22
+ --category <cat> (bots) Filter the catalog to one category id
23
+ --slug <slug> (bots) Show a single bot's full port + param spec
24
+ --out <file> (export) Write JSON to a file instead of stdout
25
+ --update <workflowId> (import) Update this workflow in place (optimistic-concurrency
26
+ guarded by the export's updatedAt — a 409 means re-export first)
27
+ --dry-run (import) Validate + resolve refs without writing; returns issues
28
+
29
+ Auth: Bearer (EXODUS_API_KEY). The canvas lives on the dashboard at /workflows.
30
+ ```
31
+
32
+ # Workflow — Run, Chain, and Build Saved Automations
33
+
34
+ An Exodus **Workflow** is a saved graph the member builds on the dashboard canvas (`/workflows`): brief and primer sources feed Genesis bots, bots feed other bots or an image node, and an Output node collects the deliverables. This skill is the CLI operator for those graphs — discover and run them, chain one into the next, and author or edit the contract without opening a browser.
35
+
36
+ Three jobs, three sections below. Read the request and jump to the one that fits; don't walk all three when the user just wants a run.
37
+
38
+ ## 1. Discover & run
39
+
40
+ The mistake to avoid is firing a run blind and wasting it on a missing input or an unmet prerequisite. Always **`describe` before `run`**.
41
+
42
+ ### Step 1 — List
43
+
44
+ ```bash
45
+ npx @aicopycoders/exodus workflow list
46
+ ```
47
+
48
+ Prints the brand's saved workflows (name, id, node/edge counts, timestamps). If the user named the workflow already, skip straight to `describe`.
49
+
50
+ ### Step 2 — Describe (read inputs, prereqs, outputs)
51
+
52
+ ```bash
53
+ npx @aicopycoders/exodus workflow describe "Launch Flow"
54
+ ```
55
+
56
+ Accepts a workflow id **or** a name. It reports three things:
57
+
58
+ - **Inputs** — each free input the run needs: field name, source (`text`, `swipe-ad`, `swipe-bundle`, `organic-url`, `ad-url`), whether it's required, and a description. These are the `--input key=value` pairs you'll pass.
59
+ - **Prerequisites** — the per-workspace primer markers the graph's primer nodes depend on, each shown **✓ stored** or **✗ missing** for *this brand*. **If any prerequisite is ✗, warn the user before running** — the run will not produce good output (or will fail) without it. Route them to the `exodus-primer` / `exodus-foundation` skill to fill the gap first, rather than burning a run.
60
+ - **Outputs** — the deliverables the Output node collects (text or image, with labels), so you know what to expect back.
61
+
62
+ ### Step 3 — Gather inputs
63
+
64
+ Fill every required input. For a free-text brief you can compose a **bespoke brief from in-session research** — the winning angle you just worked out, the swipe you analyzed — and pass it inline (`--input brief="..."`) or, if it's long, write it to a project file and pass it with `@file` (see Chain, below). For URL/swipe sources, pass the value the source expects.
65
+
66
+ ### Step 4 — Run and collect
67
+
68
+ ```bash
69
+ npx @aicopycoders/exodus workflow run "Launch Flow" --input brief="new cortisol offer, problem-aware" --wait
70
+ ```
71
+
72
+ `--wait` polls to a terminal status and prints the run's collected outputs — text inline, image URLs. Without `--wait` the command returns the `runId` and you poll it yourself:
73
+
74
+ ```bash
75
+ npx @aicopycoders/exodus workflow status --id <runId>
76
+ ```
77
+
78
+ Report the outputs plus a short take. Don't call a run "done" off the kickoff line alone — a run reaches `completed`, `partial`, or `failed`; a `partial` means some nodes were skipped or failed, so read the status before declaring victory.
79
+
80
+ ## 2. Chain — feed one workflow's output into the next
81
+
82
+ The chaining surface is the run's flattened outputs. After a `--wait` run (or once `status` shows terminal), pull the machine-readable outputs:
83
+
84
+ ```bash
85
+ npx @aicopycoders/exodus workflow status --id <runId> --json
86
+ ```
87
+
88
+ The response carries a top-level `outputs` array — each entry is `{ nodeId, botSlug?, type, label, text?, imageUrl?, imageId? }`. Extract the text you want (e.g. `.outputs[].text`) and pass it into the next workflow's input:
89
+
90
+ ```bash
91
+ # Inline, for short outputs
92
+ npx @aicopycoders/exodus workflow run "Ad Writer" --input hooks="<text from run A>" --wait
93
+ ```
94
+
95
+ **The file-as-persistence convention.** For anything reusable or long — a brief, a sales letter, a hook pool — write the text to a **markdown file in the project** and pass it with `@file`. This is the deliberate v1 alternative to a server-side store: the file *is* the persistence layer, versioned in the project alongside the brand's other artifacts.
96
+
97
+ ```bash
98
+ # Save run A's text output to a project file, then feed it to workflow B
99
+ npx @aicopycoders/exodus workflow status --id <runId> --json # copy .outputs[].text into state/hooks.md
100
+ npx @aicopycoders/exodus workflow run "Ad Writer" --input hooks=@state/hooks.md --wait
101
+ ```
102
+
103
+ `@path` is resolved from the current directory. If the literal value you need actually starts with `@`, escape it as `@@` (e.g. `--input handle=@@brand` passes the string `@brand`).
104
+
105
+ Use this pattern to compose pipelines the canvas doesn't wire directly: run the research/hook workflow, persist its best output as a file, then run the writing workflow off that file — reproducibly, because the file stays.
106
+
107
+ ## 3. Build & edit — author a workflow contract
108
+
109
+ The documented way to learn the contract format is to **export an existing workflow and study it** — there is no separate schema doc. The loop is: learn the bots → learn the format → compose → dry-run until clean → import → review on the canvas.
110
+
111
+ ### Step 1 — Learn the bot catalog
112
+
113
+ ```bash
114
+ npx @aicopycoders/exodus workflow bots # full catalog, grouped by category
115
+ npx @aicopycoders/exodus workflow bots --category writing # one category
116
+ npx @aicopycoders/exodus workflow bots --slug new-hook-bot # one bot's full port + param spec
117
+ npx @aicopycoders/exodus workflow bots --json # raw catalog for machine use
118
+ ```
119
+
120
+ Per bot you get its **input ports** (id, what it accepts — `text` / `primer` / `image`, whether it's required, and for primer ports which `primerKinds` gate it — `body` / `hook` / `headline` / `summary`), its **params** (select/text/toggle/number/multiselect, with options and defaults), and its output type. This is what tells you which node can wire into which — a port only accepts an edge whose source produces a type it lists in `accepts`.
121
+
122
+ ### Step 2 — Learn the format by exporting
123
+
124
+ ```bash
125
+ npx @aicopycoders/exodus workflow export "Launch Flow" --out launch.json
126
+ ```
127
+
128
+ Study `launch.json`: it's the `exodus-workflow` contract — `nodes` (each with `id`, `kind` = `brief`/`bot`/`primer`/`image`/`output`, a `config`, and an optional `position`) and `edges` (`source`/`sourceHandle` → `target`/`targetHandle`, the handles being port ids). An export also carries `workflowId` + `updatedAt` at the top — those are update anchors, not part of the graph.
129
+
130
+ ### Step 3 — Compose the contract
131
+
132
+ Author the JSON by hand or by adapting an export. **Node positions are optional** — leave them out and the server auto-layouts the graph on import; you don't need to compute coordinates.
133
+
134
+ ### Step 4 — Dry-run like a compiler loop
135
+
136
+ ```bash
137
+ npx @aicopycoders/exodus workflow import launch.json --dry-run
138
+ ```
139
+
140
+ `--dry-run` validates and resolves refs without writing. It returns compiler-style issues — each names the **node id**, the **port id** where relevant, and a concrete **remedy** (the exact edit that fixes it). Codes you'll see include `dangling-edge`, `unknown-port`, `type-mismatch`, `missing-required-input`, `duplicate-input`, `cycle`, `unknown-kind`, and `bad-config`. Fix, re-run `--dry-run`, repeat until it's clean — same loop as fixing type errors.
141
+
142
+ ### Step 5 — Import (create or update)
143
+
144
+ ```bash
145
+ # Create a new workflow
146
+ npx @aicopycoders/exodus workflow import launch.json
147
+
148
+ # Update an existing workflow in place
149
+ npx @aicopycoders/exodus workflow import launch.json --update wf_123
150
+ ```
151
+
152
+ Then **tell the user to review it on the canvas at `/workflows`** — the CLI writes the graph; the human eye confirms it reads right.
153
+
154
+ **Two rules for edits (`--update`):**
155
+
156
+ - **Always export a backup first.** v1 has no version history — a destructive `--update` overwrites the live workflow with no undo. `export --out backup.json` before you import over it.
157
+ - **A 409 means the canvas moved.** `--update` sends the export's `updatedAt` as an optimistic-concurrency guard. If someone edited the workflow on the dashboard since your export, the import 409s. Re-export the current workflow, reapply your edits onto the fresh contract, and import again — never force past a 409, or you'll clobber their change.
158
+
159
+ ## Failure handling
160
+
161
+ - **`describe` shows a ✗ prerequisite** — don't run yet. Fill the primer via `exodus-primer` / `exodus-foundation`, then re-check `describe`.
162
+ - **Run comes back `partial` or `failed`** — read `status --id <runId>` (drop `--json` for a readable view) to see which node failed and why before re-running.
163
+ - **`import --dry-run` issues** — fix the exact node/port each issue names using its remedy; don't guess.
164
+ - **`import --update` 409** — re-export, reapply, re-import (see Step 5). This is a conflict, not a bug.
165
+ - **Auth / whoami failure** — run `npx @aicopycoders/exodus doctor` and confirm `EXODUS_API_KEY`.
166
+ - **Wrong brand** — workflows are per-brand. `npx @aicopycoders/exodus brand current` to confirm you're on the brand you think you are; switch with the `exodus-brand` skill.
167
+
168
+ ## Not the right skill for
169
+
170
+ - **Running a single Genesis writing pass** (brief → copy in two voices) — that's `exodus-genesis`. Reach here when the member has a *saved multi-node workflow* to run or build.
171
+ - **One-off image renders** — `exodus-image` / `exodus-creative` / `exodus-template`.
172
+ - **Building the primer a workflow depends on** — `exodus-primer` (winning ads) or `exodus-foundation` (no ads).
173
+ - **Finding a past run's output doc** — `exodus-browse`.
@@ -18,6 +18,7 @@ const OTHER_COMMANDS = new Set([
18
18
  "init",
19
19
  "migrate",
20
20
  "segment",
21
+ "workflow",
21
22
  ]);
22
23
  const HIDDEN_COMMANDS = new Set([
23
24
  "creative",
@@ -0,0 +1,271 @@
1
+ import { type ApiResponse } from "../lib/client.js";
2
+ import { type PollOptions, type PollResult } from "../lib/poll.js";
3
+ export declare const helpText: string;
4
+ export type WorkflowNodeKind = "brief" | "bot" | "primer" | "image" | "output";
5
+ export interface WorkflowNode {
6
+ id: string;
7
+ kind: WorkflowNodeKind;
8
+ position: {
9
+ x: number;
10
+ y: number;
11
+ };
12
+ config: Record<string, unknown>;
13
+ }
14
+ export interface WorkflowEdge {
15
+ id: string;
16
+ source: string;
17
+ sourceHandle: string;
18
+ target: string;
19
+ targetHandle: string;
20
+ }
21
+ export interface WorkflowContractJson {
22
+ contract: "exodus-workflow";
23
+ version: number;
24
+ workflowId?: string;
25
+ updatedAt?: string;
26
+ name: string;
27
+ description?: string;
28
+ nodes: WorkflowNode[];
29
+ edges: WorkflowEdge[];
30
+ }
31
+ export interface UnresolvedWorkflowRef {
32
+ nodeId: string;
33
+ ref: "persona" | "primer";
34
+ value: string;
35
+ message: string;
36
+ }
37
+ export interface WorkflowImportResult {
38
+ name: string;
39
+ created: boolean;
40
+ workflowId?: string;
41
+ nodeCount: number;
42
+ edgeCount: number;
43
+ unresolved: UnresolvedWorkflowRef[];
44
+ warnings: string[];
45
+ }
46
+ export type GraphIssueCode = "bad-shape" | "unknown-kind" | "duplicate-node-id" | "dangling-edge" | "unknown-port" | "type-mismatch" | "duplicate-input" | "cycle" | "missing-required-input" | "bad-config";
47
+ export interface GraphIssue {
48
+ code: GraphIssueCode;
49
+ message: string;
50
+ nodeId?: string;
51
+ edgeId?: string;
52
+ portId?: string;
53
+ remedy?: string;
54
+ }
55
+ export type WorkflowImportErrorCode = "invalid-graph" | "conflict" | "forbidden" | "not-found";
56
+ export interface WorkflowImportError {
57
+ code: WorkflowImportErrorCode;
58
+ message: string;
59
+ issues?: GraphIssue[];
60
+ currentUpdatedAt?: string;
61
+ }
62
+ export type WorkflowPortType = "text" | "primer" | "image";
63
+ export type WorkflowPrimerKind = "body" | "hook" | "headline" | "summary";
64
+ export type WorkflowParamKind = "select" | "text" | "textarea" | "toggle" | "number" | "multiselect";
65
+ export interface CatalogInput {
66
+ id: string;
67
+ label: string;
68
+ accepts: WorkflowPortType[];
69
+ required: boolean;
70
+ multi?: boolean;
71
+ primerKinds?: WorkflowPrimerKind[];
72
+ }
73
+ export interface CatalogParam {
74
+ key: string;
75
+ label: string;
76
+ kind: WorkflowParamKind;
77
+ options?: string[];
78
+ min?: number;
79
+ max?: number;
80
+ required?: boolean;
81
+ help?: string;
82
+ default?: string;
83
+ }
84
+ export interface CatalogBot {
85
+ slug: string;
86
+ name: string;
87
+ blurb: string;
88
+ category: string;
89
+ categoryLabel: string;
90
+ inputs: CatalogInput[];
91
+ params: CatalogParam[];
92
+ outputType: string;
93
+ splitsOutput: boolean;
94
+ }
95
+ export interface WorkflowCatalog {
96
+ catalog: "exodus-workflow-catalog";
97
+ version: 1;
98
+ bots: CatalogBot[];
99
+ vocabulary: {
100
+ nodeKinds: string[];
101
+ briefSources: string[];
102
+ primerKinds: string[];
103
+ imageModels: string[];
104
+ aspectRatios: string[];
105
+ imageQuantityModes: string[];
106
+ categories: Array<{
107
+ id: string;
108
+ label: string;
109
+ }>;
110
+ };
111
+ customBot: {
112
+ slug: "custom";
113
+ configKey: "customSlug";
114
+ inputs: CatalogInput[];
115
+ summaryOnlySlugs: string[];
116
+ notes: string;
117
+ };
118
+ }
119
+ export type WorkflowBriefSource = "text" | "swipe-ad" | "swipe-bundle" | "organic-url" | "ad-url";
120
+ export interface WorkflowInputDescriptor {
121
+ fieldName: string;
122
+ nodeId: string;
123
+ source: WorkflowBriefSource;
124
+ required: boolean;
125
+ description?: string;
126
+ bundleSize?: number;
127
+ }
128
+ export interface WorkflowPrerequisiteDescriptor {
129
+ primerKind: WorkflowPrimerKind;
130
+ nodeIds: string[];
131
+ }
132
+ export interface WorkflowOutputDescriptor {
133
+ type: "text" | "image";
134
+ label: string;
135
+ nodeId: string;
136
+ botSlug?: string;
137
+ }
138
+ export interface WorkflowDescribeResponse {
139
+ workflowId: string;
140
+ name: string;
141
+ description?: string;
142
+ updatedAt: string;
143
+ inputs: WorkflowInputDescriptor[];
144
+ prerequisites: Array<WorkflowPrerequisiteDescriptor & {
145
+ stored: boolean;
146
+ }>;
147
+ outputs: WorkflowOutputDescriptor[];
148
+ }
149
+ export interface WorkflowListItem {
150
+ _id: string;
151
+ name: string;
152
+ description?: string;
153
+ nodeCount: number;
154
+ edgeCount: number;
155
+ createdAt: string;
156
+ updatedAt: string;
157
+ }
158
+ export interface WorkflowListResponse {
159
+ workflows: WorkflowListItem[];
160
+ }
161
+ export type WorkflowRunStatus = "queued" | "running" | "completed" | "partial" | "failed";
162
+ export type WorkflowNodeRunStatus = "idle" | "running" | "done" | "failed" | "skipped";
163
+ export type WorkflowArtifact = {
164
+ type: "text";
165
+ text: string;
166
+ label?: string;
167
+ } | {
168
+ type: "primer";
169
+ text: string;
170
+ primerKind: string;
171
+ } | {
172
+ type: "image";
173
+ storageId: string;
174
+ imageUrl?: string;
175
+ };
176
+ export interface WorkflowRunOutput {
177
+ nodeId: string;
178
+ botSlug?: string;
179
+ type: "text" | "image";
180
+ label: string;
181
+ text?: string;
182
+ imageUrl?: string;
183
+ imageId?: string;
184
+ }
185
+ export interface WorkflowRunNode {
186
+ nodeId: string;
187
+ kind: string;
188
+ status: WorkflowNodeRunStatus;
189
+ error?: string;
190
+ startedAt?: number;
191
+ completedAt?: number;
192
+ outputs: WorkflowArtifact[];
193
+ }
194
+ export interface WorkflowCounts {
195
+ done: number;
196
+ failed: number;
197
+ skipped: number;
198
+ total: number;
199
+ }
200
+ export interface WorkflowRun {
201
+ _id: string;
202
+ workflowId: string;
203
+ workflowName: string;
204
+ status: WorkflowRunStatus;
205
+ error?: string;
206
+ counts?: WorkflowCounts;
207
+ inputs: Record<string, string>;
208
+ triggerRunId?: string;
209
+ createdAt: number;
210
+ startedAt?: number;
211
+ completedAt?: number;
212
+ isTerminal: boolean;
213
+ nodes: WorkflowRunNode[];
214
+ outputs?: WorkflowRunOutput[];
215
+ }
216
+ export type WorkflowRunProjection = Omit<WorkflowRun, "nodes"> & {
217
+ nodes?: never;
218
+ };
219
+ export interface WorkflowRunDeps {
220
+ get: (path: string) => Promise<ApiResponse<unknown>>;
221
+ post: (path: string, body: unknown) => Promise<ApiResponse<unknown>>;
222
+ readFile: (path: string) => string;
223
+ writeFile: (path: string, text: string) => void;
224
+ poll: (opts: PollOptions) => Promise<PollResult>;
225
+ }
226
+ export interface FlowResult {
227
+ code: number;
228
+ lines: string[];
229
+ }
230
+ interface RunFlowOptions {
231
+ inputs: Record<string, string>;
232
+ wait: boolean;
233
+ json: boolean;
234
+ onProgressLine?: (line: string) => void;
235
+ }
236
+ export declare function parseInputFlags(args: string[], readFile?: (path: string) => string): Record<string, string>;
237
+ export declare function formatWorkflowList(workflows: WorkflowListItem[]): string;
238
+ export declare function formatRecentRuns(runs: WorkflowRunProjection[]): string;
239
+ export declare function formatImportSummary(result: WorkflowImportResult, mode?: {
240
+ dryRun?: boolean;
241
+ update?: boolean;
242
+ }): string;
243
+ export declare function formatWorkflowRun(run: WorkflowRun): string;
244
+ export declare function formatDescribe(res: WorkflowDescribeResponse): string;
245
+ export declare function formatBotsList(catalog: WorkflowCatalog, category?: string): string;
246
+ export declare function formatBotDetail(bot: CatalogBot): string;
247
+ export declare function resolveWorkflowId(ref: string, deps: WorkflowRunDeps): Promise<string>;
248
+ export declare function listFlow(json: boolean, deps: WorkflowRunDeps): Promise<FlowResult>;
249
+ export declare function describeFlow(workflowRef: string, opts: {
250
+ json: boolean;
251
+ }, deps: WorkflowRunDeps): Promise<FlowResult>;
252
+ export declare function botsFlow(opts: {
253
+ category?: string;
254
+ slug?: string;
255
+ json: boolean;
256
+ }, deps: WorkflowRunDeps): Promise<FlowResult>;
257
+ export declare function runFlow(workflowRef: string, opts: RunFlowOptions, deps: WorkflowRunDeps): Promise<FlowResult>;
258
+ export declare function statusFlow(opts: {
259
+ id?: string;
260
+ json: boolean;
261
+ }, deps: WorkflowRunDeps): Promise<FlowResult>;
262
+ export declare function exportFlow(workflowRef: string, opts: {
263
+ out?: string;
264
+ }, deps: WorkflowRunDeps): Promise<FlowResult>;
265
+ export declare function importFlow(file: string, opts: {
266
+ dryRun: boolean;
267
+ json: boolean;
268
+ update?: string;
269
+ }, deps: WorkflowRunDeps): Promise<FlowResult>;
270
+ export declare function run(flags: Record<string, string | boolean>): Promise<void>;
271
+ export {};
@@ -0,0 +1,744 @@
1
+ import fs from "node:fs";
2
+ import { apiGet, apiPost } from "../lib/client.js";
3
+ import { formatError } from "../lib/format.js";
4
+ import { pollUntilDone } from "../lib/poll.js";
5
+ export const helpText = `
6
+ exodus workflow — List, describe, run, inspect, import, and export saved workflows
7
+
8
+ Usage:
9
+ exodus workflow list [--json]
10
+ exodus workflow describe <workflowId|name> [--json]
11
+ exodus workflow bots [--category <cat>] [--slug <slug>] [--json]
12
+ exodus workflow run <workflowId|name> [--input key=value ...] [--wait] [--json]
13
+ exodus workflow status [--id <runId>] [--json]
14
+ exodus workflow export <workflowId|name> [--out <file>]
15
+ exodus workflow import <file> [--update <workflowId>] [--dry-run] [--json]
16
+
17
+ Flags:
18
+ --json Machine-readable JSON output
19
+ --category <cat> (bots) Filter the catalog to one category id
20
+ --slug <slug> (bots) Show a single bot's full port + param spec
21
+ --input key=value Repeatable workflow run input; values may contain "="
22
+ --input key=@path loads the value from a file (path is
23
+ resolved from the current directory); --input key=@@text
24
+ keeps a leading "@" as a literal character.
25
+ --wait Poll until the workflow run reaches a terminal status
26
+ --id <runId> Workflow run id for status detail
27
+ --out <file> Write export JSON to a file instead of stdout
28
+ --update <workflowId> (import) Update this existing workflow in place instead
29
+ of creating a new one. Sends the contract's updatedAt as
30
+ an optimistic-concurrency guard — if it 409s, re-export
31
+ the workflow, reapply edits, and import again.
32
+ --dry-run Preview import (validate + resolve refs) without writing
33
+
34
+ Examples:
35
+ exodus workflow describe "Launch Flow"
36
+ exodus workflow bots
37
+ exodus workflow bots --category writing
38
+ exodus workflow bots --slug new-hook-bot
39
+ exodus workflow run "Launch Flow" --input brief="new offer" --wait
40
+ exodus workflow run "Launch Flow" --input brief=@brief.txt
41
+ exodus workflow status --id wr_123
42
+ exodus workflow export "Launch Flow" --out workflow.json
43
+ exodus workflow import workflow.json --dry-run
44
+ exodus workflow import workflow.json --update wf_123
45
+
46
+ Notes:
47
+ workflow bots --json emits the FULL catalog response verbatim (--category /
48
+ --slug filters are ignored in that mode). workflow bots --slug <slug> --json
49
+ emits just that one bot's catalog JSON.
50
+ `.trim();
51
+ const LIST_PATH = "/api/v2/workflows";
52
+ const RUN_PATH = "/api/v2/workflows/run";
53
+ const STATUS_PATH = "/api/v2/workflow";
54
+ const EXPORT_PATH = "/api/v2/workflows/export";
55
+ const IMPORT_PATH = "/api/v2/workflows/import";
56
+ const DESCRIBE_PATH = "/api/v2/workflows/describe";
57
+ const CATALOG_PATH = "/api/v2/workflows/catalog";
58
+ const VALUE_FLAGS = new Set(["id", "input", "out", "category", "slug", "update"]);
59
+ const defaultDeps = {
60
+ get: (path) => apiGet(path),
61
+ post: (path, body) => apiPost(path, body),
62
+ readFile: (path) => fs.readFileSync(path, "utf-8"),
63
+ writeFile: (path, text) => fs.writeFileSync(path, text, "utf-8"),
64
+ poll: (opts) => pollUntilDone(opts),
65
+ };
66
+ function asErrorResult(res, json) {
67
+ return {
68
+ code: 1,
69
+ lines: json
70
+ ? [JSON.stringify({ ok: false, status: res.status, data: res.data })]
71
+ : [formatError(res)],
72
+ };
73
+ }
74
+ function isRecord(value) {
75
+ return typeof value === "object" && value !== null && !Array.isArray(value);
76
+ }
77
+ function dateOnly(value) {
78
+ if (!value)
79
+ return "-";
80
+ const parsed = new Date(value);
81
+ if (Number.isNaN(parsed.getTime()))
82
+ return String(value);
83
+ return parsed.toISOString().slice(0, 10);
84
+ }
85
+ function table(headers, rows) {
86
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((row) => row[i]?.length ?? 0)));
87
+ const fmt = (row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd();
88
+ return [fmt(headers), fmt(headers.map((h) => "-".repeat(h.length))), ...rows.map(fmt)].join("\n");
89
+ }
90
+ function statusIcon(status) {
91
+ if (status === "completed" || status === "done")
92
+ return "✓";
93
+ if (status === "failed")
94
+ return "✗";
95
+ if (status === "skipped")
96
+ return "-";
97
+ return "…";
98
+ }
99
+ function truncateText(text, max = 200) {
100
+ const normalized = text.replace(/\s+/g, " ").trim();
101
+ if (normalized.length <= max)
102
+ return normalized;
103
+ return `${normalized.slice(0, max - 3).trimEnd()}...`;
104
+ }
105
+ function formatCounts(counts) {
106
+ if (!counts)
107
+ return "";
108
+ return `done=${counts.done}/${counts.total}, failed=${counts.failed}, skipped=${counts.skipped}`;
109
+ }
110
+ function outputLines(output) {
111
+ if (output.type === "text")
112
+ return [` text: ${truncateText(output.text)}`];
113
+ if (output.type === "primer") {
114
+ return [` primer:${output.primerKind}: ${truncateText(output.text)}`];
115
+ }
116
+ return [` image: ${output.imageUrl ?? output.storageId}`];
117
+ }
118
+ function progressLine(node) {
119
+ const err = node.error ? ` — error: ${node.error}` : "";
120
+ return ` ${statusIcon(node.status)} ${node.nodeId} (${node.kind}) ${node.status}${err}`;
121
+ }
122
+ function expandInputValue(key, value, readFile) {
123
+ if (value.startsWith("@@"))
124
+ return value.slice(1);
125
+ if (value.startsWith("@")) {
126
+ const filePath = value.slice(1);
127
+ if (!filePath) {
128
+ throw new Error(`--input ${key}=@<file> needs a file path after "@"`);
129
+ }
130
+ if (!readFile) {
131
+ throw new Error(`--input ${key}: cannot load @${filePath} here (no file access)`);
132
+ }
133
+ try {
134
+ return readFile(filePath);
135
+ }
136
+ catch (e) {
137
+ const msg = e instanceof Error ? e.message : String(e);
138
+ throw new Error(`--input ${key}: could not read file "${filePath}": ${msg}`);
139
+ }
140
+ }
141
+ return value;
142
+ }
143
+ export function parseInputFlags(args, readFile) {
144
+ const inputs = {};
145
+ for (let i = 0; i < args.length; i++) {
146
+ const arg = args[i];
147
+ let raw;
148
+ if (arg === "--input") {
149
+ raw = args[i + 1];
150
+ i++;
151
+ }
152
+ else if (arg.startsWith("--input=")) {
153
+ raw = arg.slice("--input=".length);
154
+ }
155
+ else {
156
+ continue;
157
+ }
158
+ if (!raw)
159
+ throw new Error("--input requires key=value");
160
+ const eq = raw.indexOf("=");
161
+ if (eq <= 0)
162
+ throw new Error(`--input must be key=value (got "${raw}")`);
163
+ const key = raw.slice(0, eq).trim();
164
+ if (!key)
165
+ throw new Error(`--input must include a key (got "${raw}")`);
166
+ inputs[key] = expandInputValue(key, raw.slice(eq + 1), readFile);
167
+ }
168
+ return inputs;
169
+ }
170
+ export function formatWorkflowList(workflows) {
171
+ if (workflows.length === 0)
172
+ return "No workflows found for the active brand.";
173
+ return table(["name", "nodes", "edges", "updated", "id"], workflows.map((w) => [
174
+ w.name,
175
+ String(w.nodeCount),
176
+ String(w.edgeCount),
177
+ dateOnly(w.updatedAt),
178
+ w._id,
179
+ ]));
180
+ }
181
+ export function formatRecentRuns(runs) {
182
+ if (runs.length === 0)
183
+ return "No workflow runs found for the active brand.";
184
+ return table(["workflow", "status", "created", "id"], runs.map((r) => [r.workflowName, r.status, dateOnly(r.createdAt), r._id]));
185
+ }
186
+ export function formatImportSummary(result, mode = {}) {
187
+ const lines = [];
188
+ const heading = mode.dryRun
189
+ ? "Workflow import preview:"
190
+ : result.created
191
+ ? "Workflow imported."
192
+ : mode.update
193
+ ? "Workflow updated."
194
+ : "Workflow import preview:";
195
+ lines.push(heading);
196
+ lines.push(`name: ${result.name}`);
197
+ if (result.workflowId)
198
+ lines.push(`workflowId: ${result.workflowId}`);
199
+ lines.push(`nodes: ${result.nodeCount}`);
200
+ lines.push(`edges: ${result.edgeCount}`);
201
+ if (result.unresolved.length > 0) {
202
+ lines.push("");
203
+ lines.push(`Unresolved references (${result.unresolved.length}):`);
204
+ for (const ref of result.unresolved) {
205
+ lines.push(` ${ref.nodeId.padEnd(20)} ${ref.ref.padEnd(7)} ${ref.value} — ${ref.message}`);
206
+ }
207
+ }
208
+ if (result.warnings.length > 0) {
209
+ lines.push("");
210
+ lines.push(`Warnings (${result.warnings.length}):`);
211
+ for (const warning of result.warnings)
212
+ lines.push(` ${warning}`);
213
+ }
214
+ return lines.join("\n");
215
+ }
216
+ export function formatWorkflowRun(run) {
217
+ const lines = [];
218
+ const counts = formatCounts(run.counts);
219
+ lines.push(`Workflow run — ${run.workflowName}`);
220
+ lines.push(`runId: ${run._id}`);
221
+ lines.push(`workflowId: ${run.workflowId}`);
222
+ if (run.triggerRunId)
223
+ lines.push(`triggerRunId: ${run.triggerRunId}`);
224
+ lines.push(`verdict: ${run.status}${counts ? ` (${counts})` : ""}`);
225
+ if (run.isTerminal)
226
+ lines.push("terminal: yes");
227
+ if (run.error)
228
+ lines.push(`error: ${run.error}`);
229
+ if (Object.keys(run.inputs ?? {}).length > 0) {
230
+ const inputs = Object.entries(run.inputs).map(([k, v]) => `${k}=${v}`).join(", ");
231
+ lines.push(`inputs: ${inputs}`);
232
+ }
233
+ if (run.nodes.length > 0) {
234
+ lines.push("");
235
+ lines.push(`Nodes (${run.nodes.length}):`);
236
+ for (const node of run.nodes) {
237
+ lines.push(progressLine(node));
238
+ for (const output of node.outputs)
239
+ lines.push(...outputLines(output));
240
+ }
241
+ }
242
+ if (run.outputs && run.outputs.length > 0) {
243
+ lines.push("");
244
+ lines.push(`Outputs (${run.outputs.length}):`);
245
+ for (const output of run.outputs)
246
+ lines.push(...runOutputLines(output));
247
+ }
248
+ return lines.join("\n");
249
+ }
250
+ function runOutputLines(output) {
251
+ const slug = output.botSlug ? ` (${output.botSlug})` : "";
252
+ if (output.type === "image") {
253
+ return [` ${output.label} [image]${slug}: ${output.imageUrl ?? output.imageId ?? "(no url)"}`];
254
+ }
255
+ const raw = output.text ?? "";
256
+ const normalized = raw.replace(/\s+/g, " ").trim();
257
+ const body = truncateText(raw, 400);
258
+ const note = normalized.length > 400 ? "\n (truncated — use --json for the full text)" : "";
259
+ return [` ${output.label} [text]${slug}:`, ` ${body}${note}`];
260
+ }
261
+ export function formatDescribe(res) {
262
+ const lines = [];
263
+ lines.push(`Workflow — ${res.name}`);
264
+ lines.push(`workflowId: ${res.workflowId}`);
265
+ if (res.description)
266
+ lines.push(`description: ${res.description}`);
267
+ lines.push(`updated: ${dateOnly(res.updatedAt)}`);
268
+ lines.push("");
269
+ lines.push(`Inputs (${res.inputs.length}):`);
270
+ if (res.inputs.length === 0) {
271
+ lines.push(" (none — this workflow takes no run inputs)");
272
+ }
273
+ else {
274
+ for (const input of res.inputs) {
275
+ const req = input.required ? "required" : "optional";
276
+ const bundle = input.bundleSize !== undefined ? `, bundle=${input.bundleSize}` : "";
277
+ lines.push(` ${input.fieldName} — ${input.source}, ${req}${bundle}`);
278
+ if (input.description)
279
+ lines.push(` ${input.description}`);
280
+ }
281
+ }
282
+ lines.push("");
283
+ lines.push(`Prerequisites (${res.prerequisites.length}):`);
284
+ if (res.prerequisites.length === 0) {
285
+ lines.push(" (none — no stored primers required)");
286
+ }
287
+ else {
288
+ for (const prereq of res.prerequisites) {
289
+ const mark = prereq.stored ? "✓ stored" : "✗ MISSING";
290
+ lines.push(` ${mark} ${prereq.primerKind} primer (nodes: ${prereq.nodeIds.join(", ")})`);
291
+ }
292
+ const missing = res.prerequisites.filter((p) => !p.stored);
293
+ if (missing.length > 0) {
294
+ lines.push("");
295
+ lines.push(` ✗ ${missing.length} primer(s) not stored for this brand — add ${missing
296
+ .map((p) => `"${p.primerKind}"`)
297
+ .join(", ")} before running, or those nodes fail.`);
298
+ }
299
+ }
300
+ lines.push("");
301
+ lines.push(`Outputs (${res.outputs.length}):`);
302
+ if (res.outputs.length === 0) {
303
+ lines.push(" (none wired into an Output node)");
304
+ }
305
+ else {
306
+ for (const output of res.outputs) {
307
+ const slug = output.botSlug ? ` (${output.botSlug})` : "";
308
+ lines.push(` ${output.label} [${output.type}]${slug}`);
309
+ }
310
+ }
311
+ return lines.join("\n");
312
+ }
313
+ function formatBotVocabulary(catalog) {
314
+ const v = catalog.vocabulary;
315
+ const lines = [];
316
+ lines.push("Vocabulary:");
317
+ lines.push(` node kinds: ${v.nodeKinds.join(", ")}`);
318
+ lines.push(` brief sources: ${v.briefSources.join(", ")}`);
319
+ lines.push(` primer kinds: ${v.primerKinds.join(", ")}`);
320
+ lines.push(` image models: ${v.imageModels.join(", ")}`);
321
+ lines.push(` aspect ratios: ${v.aspectRatios.join(", ")}`);
322
+ lines.push(` custom bot: set a bot node's slug to "${catalog.customBot.slug}" and ` +
323
+ `config.${catalog.customBot.configKey}=<genesis-slug> to reach any bot not listed here.`);
324
+ return lines.join("\n");
325
+ }
326
+ export function formatBotsList(catalog, category) {
327
+ const bots = category
328
+ ? catalog.bots.filter((b) => b.category === category)
329
+ : catalog.bots;
330
+ if (bots.length === 0) {
331
+ const known = catalog.vocabulary.categories.map((c) => c.id).join(", ");
332
+ return category
333
+ ? `No bots in category "${category}". Known categories: ${known}`
334
+ : "No bots in the catalog.";
335
+ }
336
+ const lines = [];
337
+ let currentCategory = null;
338
+ for (const bot of bots) {
339
+ if (bot.category !== currentCategory) {
340
+ if (currentCategory !== null)
341
+ lines.push("");
342
+ lines.push(`${bot.categoryLabel} (${bot.category})`);
343
+ currentCategory = bot.category;
344
+ }
345
+ lines.push(` ${bot.slug} — ${bot.blurb}`);
346
+ }
347
+ if (!category) {
348
+ lines.push("");
349
+ lines.push(formatBotVocabulary(catalog));
350
+ }
351
+ return lines.join("\n");
352
+ }
353
+ export function formatBotDetail(bot) {
354
+ const lines = [];
355
+ lines.push(`${bot.name} (slug: ${bot.slug})`);
356
+ lines.push(`category: ${bot.categoryLabel} (${bot.category})`);
357
+ lines.push(`blurb: ${bot.blurb}`);
358
+ lines.push(`outputType: ${bot.outputType}`);
359
+ lines.push(`splits: ${bot.splitsOutput ? "yes (can split into per-item deliverables)" : "no"}`);
360
+ lines.push("");
361
+ lines.push(`Input ports (${bot.inputs.length}):`);
362
+ if (bot.inputs.length === 0) {
363
+ lines.push(" (none)");
364
+ }
365
+ else {
366
+ for (const input of bot.inputs) {
367
+ const bits = [`accepts ${input.accepts.join("/")}`, input.required ? "required" : "optional"];
368
+ if (input.multi)
369
+ bits.push("multi");
370
+ if (input.primerKinds && input.primerKinds.length > 0) {
371
+ bits.push(`primer-gate: ${input.primerKinds.join("/")}`);
372
+ }
373
+ lines.push(` ${input.id} — ${bits.join(", ")}`);
374
+ }
375
+ }
376
+ lines.push("");
377
+ lines.push(`Params (${bot.params.length}):`);
378
+ if (bot.params.length === 0) {
379
+ lines.push(" (none)");
380
+ }
381
+ else {
382
+ for (const param of bot.params) {
383
+ const bits = [param.kind];
384
+ if (param.options && param.options.length > 0)
385
+ bits.push(`options: ${param.options.join("|")}`);
386
+ if (param.min !== undefined)
387
+ bits.push(`min ${param.min}`);
388
+ if (param.max !== undefined)
389
+ bits.push(`max ${param.max}`);
390
+ if (param.required)
391
+ bits.push("required");
392
+ if (param.default !== undefined)
393
+ bits.push(`default "${param.default}"`);
394
+ lines.push(` config.${param.key} — ${param.label} (${bits.join(", ")})`);
395
+ if (param.help)
396
+ lines.push(` ${param.help}`);
397
+ }
398
+ }
399
+ return lines.join("\n");
400
+ }
401
+ function graphIssueLines(issue) {
402
+ const node = issue.nodeId ? ` [node ${issue.nodeId}]` : "";
403
+ const port = issue.portId ? ` [port ${issue.portId}]` : "";
404
+ const edge = !issue.nodeId && issue.edgeId ? ` [edge ${issue.edgeId}]` : "";
405
+ const out = [`${issue.code}${node}${edge}${port}: ${issue.message}`];
406
+ if (issue.remedy)
407
+ out.push(` fix: ${issue.remedy}`);
408
+ return out;
409
+ }
410
+ function formatImportError(res) {
411
+ const data = isRecord(res.data) ? res.data : {};
412
+ const err = isRecord(data.error) ? data.error : {};
413
+ const code = typeof err.code === "string" ? err.code : undefined;
414
+ const message = typeof err.message === "string" ? err.message : "Import failed";
415
+ if (code === "INVALID_GRAPH") {
416
+ const issues = Array.isArray(data.issues) ? data.issues : [];
417
+ const lines = [`Import rejected — invalid graph: ${message}`];
418
+ if (issues.length > 0) {
419
+ lines.push("");
420
+ lines.push(`Issues (${issues.length}):`);
421
+ for (const issue of issues)
422
+ lines.push(...graphIssueLines(issue));
423
+ }
424
+ return lines.join("\n");
425
+ }
426
+ if (code === "CONFLICT") {
427
+ const lines = [`Import conflict — ${message}`];
428
+ if (typeof data.currentUpdatedAt === "string") {
429
+ lines.push(`current updatedAt: ${data.currentUpdatedAt}`);
430
+ }
431
+ const remedy = typeof data.remedy === "string"
432
+ ? data.remedy
433
+ : "Re-export the workflow, reapply your edits, and import again with the fresh updatedAt.";
434
+ lines.push(`fix: ${remedy}`);
435
+ return lines.join("\n");
436
+ }
437
+ return formatError(res);
438
+ }
439
+ export async function resolveWorkflowId(ref, deps) {
440
+ const res = await deps.get(LIST_PATH);
441
+ if (!res.ok)
442
+ throw new Error(formatError(res));
443
+ const workflows = res.data.workflows ?? [];
444
+ const match = workflows.find((w) => w.name.toLowerCase() === ref.toLowerCase());
445
+ return match?._id ?? ref;
446
+ }
447
+ export async function listFlow(json, deps) {
448
+ const res = await deps.get(LIST_PATH);
449
+ if (!res.ok)
450
+ return asErrorResult(res, json);
451
+ const data = res.data;
452
+ return {
453
+ code: 0,
454
+ lines: json ? [JSON.stringify(data)] : [formatWorkflowList(data.workflows ?? [])],
455
+ };
456
+ }
457
+ export async function describeFlow(workflowRef, opts, deps) {
458
+ let workflowId;
459
+ try {
460
+ workflowId = await resolveWorkflowId(workflowRef, deps);
461
+ }
462
+ catch (e) {
463
+ return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
464
+ }
465
+ const res = await deps.get(`${DESCRIBE_PATH}?id=${encodeURIComponent(workflowId)}`);
466
+ if (!res.ok)
467
+ return asErrorResult(res, opts.json);
468
+ if (opts.json)
469
+ return { code: 0, lines: [JSON.stringify(res.data)] };
470
+ return { code: 0, lines: [formatDescribe(res.data)] };
471
+ }
472
+ export async function botsFlow(opts, deps) {
473
+ const res = await deps.get(CATALOG_PATH);
474
+ if (!res.ok)
475
+ return asErrorResult(res, opts.json);
476
+ const catalog = res.data;
477
+ if (opts.slug) {
478
+ const bot = catalog.bots.find((b) => b.slug === opts.slug);
479
+ if (!bot) {
480
+ const line = opts.json
481
+ ? JSON.stringify({ ok: false, error: `unknown bot slug "${opts.slug}"` })
482
+ : `No bot with slug "${opts.slug}" in the catalog. Run "exodus workflow bots" to list them.`;
483
+ return { code: 1, lines: [line] };
484
+ }
485
+ return { code: 0, lines: [opts.json ? JSON.stringify(bot) : formatBotDetail(bot)] };
486
+ }
487
+ if (opts.json)
488
+ return { code: 0, lines: [JSON.stringify(catalog)] };
489
+ return { code: 0, lines: [formatBotsList(catalog, opts.category)] };
490
+ }
491
+ export async function runFlow(workflowRef, opts, deps) {
492
+ let workflowId;
493
+ try {
494
+ workflowId = await resolveWorkflowId(workflowRef, deps);
495
+ }
496
+ catch (e) {
497
+ return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
498
+ }
499
+ const body = {
500
+ workflowId,
501
+ ...(Object.keys(opts.inputs).length > 0 ? { inputs: opts.inputs } : {}),
502
+ };
503
+ const start = await deps.post(RUN_PATH, body);
504
+ if (!start.ok)
505
+ return asErrorResult(start, opts.json);
506
+ const data = start.data;
507
+ const base = { ...data, workflowId };
508
+ if (opts.json && !opts.wait)
509
+ return { code: 0, lines: [JSON.stringify(base)] };
510
+ const lines = opts.json
511
+ ? []
512
+ : [
513
+ "Workflow run started.",
514
+ `runId: ${data.runId}`,
515
+ `triggerRunId: ${data.triggerRunId}`,
516
+ `Poll: exodus workflow status --id ${data.runId}`,
517
+ ];
518
+ if (!opts.wait)
519
+ return { code: 0, lines };
520
+ if (!opts.json && opts.onProgressLine) {
521
+ for (const line of lines)
522
+ opts.onProgressLine(line);
523
+ lines.length = 0;
524
+ }
525
+ const seen = new Map();
526
+ const pollResult = await deps.poll({
527
+ path: `${STATUS_PATH}?runId=${encodeURIComponent(data.runId)}`,
528
+ intervalMs: 3_000,
529
+ timeoutMs: 60 * 60 * 1000,
530
+ terminalStatuses: ["completed", "partial", "failed"],
531
+ onProgress: (raw) => {
532
+ if (opts.json || !opts.onProgressLine)
533
+ return;
534
+ const nodes = Array.isArray(raw["nodes"]) ? raw["nodes"] : [];
535
+ for (const node of nodes) {
536
+ if (seen.get(node.nodeId) === node.status)
537
+ continue;
538
+ seen.set(node.nodeId, node.status);
539
+ opts.onProgressLine(progressLine(node));
540
+ }
541
+ },
542
+ });
543
+ if (opts.json) {
544
+ return {
545
+ code: pollResult.ok ? 0 : 1,
546
+ lines: [JSON.stringify({ ...base, result: pollResult.data, timedOut: pollResult.timedOut })],
547
+ };
548
+ }
549
+ if (pollResult.timedOut) {
550
+ lines.push(`Timed out waiting. Check later: exodus workflow status --id ${data.runId}`);
551
+ return { code: 1, lines };
552
+ }
553
+ lines.push("");
554
+ if (isRecord(pollResult.data) && typeof pollResult.data["_id"] === "string") {
555
+ lines.push(formatWorkflowRun(pollResult.data));
556
+ }
557
+ else {
558
+ lines.push(`Polling failed: ${JSON.stringify(pollResult.data)}`);
559
+ }
560
+ return { code: pollResult.ok ? 0 : 1, lines };
561
+ }
562
+ export async function statusFlow(opts, deps) {
563
+ const path = opts.id ? `${STATUS_PATH}?runId=${encodeURIComponent(opts.id)}` : STATUS_PATH;
564
+ const res = await deps.get(path);
565
+ if (!res.ok)
566
+ return asErrorResult(res, opts.json);
567
+ if (opts.json)
568
+ return { code: 0, lines: [JSON.stringify(res.data)] };
569
+ if (opts.id)
570
+ return { code: 0, lines: [formatWorkflowRun(res.data)] };
571
+ const runs = (res.data.runs ?? []);
572
+ return { code: 0, lines: [formatRecentRuns(runs)] };
573
+ }
574
+ export async function exportFlow(workflowRef, opts, deps) {
575
+ let workflowId;
576
+ try {
577
+ workflowId = await resolveWorkflowId(workflowRef, deps);
578
+ }
579
+ catch (e) {
580
+ return { code: 1, lines: [e instanceof Error ? e.message : String(e)] };
581
+ }
582
+ const res = await deps.get(`${EXPORT_PATH}?id=${encodeURIComponent(workflowId)}`);
583
+ if (!res.ok)
584
+ return asErrorResult(res, false);
585
+ const doc = JSON.stringify(res.data, null, 2);
586
+ if (opts.out) {
587
+ deps.writeFile(opts.out, `${doc}\n`);
588
+ return { code: 0, lines: [`Wrote workflow contract to ${opts.out}.`] };
589
+ }
590
+ return { code: 0, lines: [doc] };
591
+ }
592
+ export async function importFlow(file, opts, deps) {
593
+ let text;
594
+ try {
595
+ text = deps.readFile(file);
596
+ }
597
+ catch {
598
+ return { code: 1, lines: [`Error: file not found: ${file}`] };
599
+ }
600
+ let parsed;
601
+ try {
602
+ parsed = JSON.parse(text);
603
+ }
604
+ catch (e) {
605
+ const msg = e instanceof Error ? e.message : String(e);
606
+ return { code: 1, lines: [`Error: ${file} is not valid JSON: ${msg}`] };
607
+ }
608
+ if (!isRecord(parsed)) {
609
+ return { code: 1, lines: [`Error: ${file} is not a workflow contract (expected a JSON object).`] };
610
+ }
611
+ const body = { ...parsed };
612
+ delete body.dryRun;
613
+ delete body.targetWorkflowId;
614
+ delete body.expectedUpdatedAt;
615
+ if (opts.dryRun)
616
+ body.dryRun = true;
617
+ if (opts.update) {
618
+ const updatedAt = typeof parsed.updatedAt === "string" ? parsed.updatedAt : undefined;
619
+ if (!updatedAt) {
620
+ return {
621
+ code: 1,
622
+ lines: [
623
+ `Error: ${file} has no "updatedAt" anchor, so drift can't be detected. ` +
624
+ `Re-export the workflow first (exodus workflow export <id> --out ${file}) and retry --update.`,
625
+ ],
626
+ };
627
+ }
628
+ body.targetWorkflowId = opts.update;
629
+ body.expectedUpdatedAt = updatedAt;
630
+ }
631
+ const res = await deps.post(IMPORT_PATH, body);
632
+ if (!res.ok) {
633
+ return {
634
+ code: 1,
635
+ lines: opts.json
636
+ ? [JSON.stringify({ ok: false, status: res.status, data: res.data })]
637
+ : [formatImportError(res)],
638
+ };
639
+ }
640
+ const data = res.data;
641
+ return {
642
+ code: 0,
643
+ lines: opts.json
644
+ ? [JSON.stringify(data)]
645
+ : [formatImportSummary(data, { dryRun: opts.dryRun, update: !!opts.update })],
646
+ };
647
+ }
648
+ function flagString(flags, name) {
649
+ const v = flags[name];
650
+ return typeof v === "string" ? v : undefined;
651
+ }
652
+ function parsePositional(args = process.argv.slice(3)) {
653
+ const out = [];
654
+ let i = 0;
655
+ while (i < args.length) {
656
+ const arg = args[i];
657
+ if (arg.startsWith("--")) {
658
+ const key = arg.slice(2).split("=", 1)[0] ?? "";
659
+ if (!arg.includes("=") && VALUE_FLAGS.has(key))
660
+ i += 2;
661
+ else
662
+ i++;
663
+ continue;
664
+ }
665
+ out.push(arg);
666
+ i++;
667
+ }
668
+ return out;
669
+ }
670
+ async function printResult(result) {
671
+ for (const line of result.lines)
672
+ console.log(line);
673
+ if (result.code !== 0)
674
+ process.exit(result.code);
675
+ }
676
+ export async function run(flags) {
677
+ const positional = parsePositional();
678
+ const [sub, ...rest] = positional;
679
+ const json = !!flags["json"];
680
+ if (!sub) {
681
+ console.log(helpText);
682
+ return;
683
+ }
684
+ if (sub === "list")
685
+ return printResult(await listFlow(json, defaultDeps));
686
+ if (sub === "describe") {
687
+ const workflowRef = rest[0];
688
+ if (!workflowRef) {
689
+ console.error("Error: workflow describe requires <workflowId|name>.");
690
+ console.log("Usage: exodus workflow describe <workflowId|name> [--json]");
691
+ process.exit(1);
692
+ }
693
+ return printResult(await describeFlow(workflowRef, { json }, defaultDeps));
694
+ }
695
+ if (sub === "bots") {
696
+ return printResult(await botsFlow({ category: flagString(flags, "category"), slug: flagString(flags, "slug"), json }, defaultDeps));
697
+ }
698
+ if (sub === "run") {
699
+ const workflowRef = rest[0];
700
+ if (!workflowRef) {
701
+ console.error("Error: workflow run requires <workflowId|name>.");
702
+ console.log("Usage: exodus workflow run <workflowId|name> [--input key=value ...] [--wait] [--json]");
703
+ process.exit(1);
704
+ }
705
+ let inputs;
706
+ try {
707
+ inputs = parseInputFlags(process.argv.slice(3), defaultDeps.readFile);
708
+ }
709
+ catch (e) {
710
+ console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
711
+ process.exit(1);
712
+ }
713
+ return printResult(await runFlow(workflowRef, {
714
+ inputs,
715
+ wait: flags["wait"] === true,
716
+ json,
717
+ onProgressLine: (line) => console.log(line),
718
+ }, defaultDeps));
719
+ }
720
+ if (sub === "status") {
721
+ return printResult(await statusFlow({ id: flagString(flags, "id"), json }, defaultDeps));
722
+ }
723
+ if (sub === "export") {
724
+ const workflowRef = rest[0];
725
+ if (!workflowRef) {
726
+ console.error("Error: workflow export requires <workflowId|name>.");
727
+ console.log("Usage: exodus workflow export <workflowId|name> [--out <file>]");
728
+ process.exit(1);
729
+ }
730
+ return printResult(await exportFlow(workflowRef, { out: flagString(flags, "out") }, defaultDeps));
731
+ }
732
+ if (sub === "import") {
733
+ const file = rest[0];
734
+ if (!file) {
735
+ console.error("Error: workflow import requires <file>.");
736
+ console.log("Usage: exodus workflow import <file> [--update <workflowId>] [--dry-run] [--json]");
737
+ process.exit(1);
738
+ }
739
+ return printResult(await importFlow(file, { dryRun: flags["dry-run"] === true, json, update: flagString(flags, "update") }, defaultDeps));
740
+ }
741
+ console.error(`Unknown subcommand: "${sub}"\n`);
742
+ console.log(helpText);
743
+ process.exit(1);
744
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicopycoders/exodus",
3
- "version": "2026.7.200-beta.4",
3
+ "version": "2026.7.200-beta.5",
4
4
  "description": "CLI + Claude Code skills for the Copycoders Exodus ad pipeline",
5
5
  "repository": {
6
6
  "type": "git",