@aicopycoders/exodus 2026.6.2400

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.
Files changed (114) hide show
  1. package/assets/references/awareness-framework.md +156 -0
  2. package/assets/references/cast-video.md +165 -0
  3. package/assets/references/copy-blocks.md +138 -0
  4. package/assets/references/creative-strategy.md +990 -0
  5. package/assets/references/editing-rules.md +81 -0
  6. package/assets/references/hook-quality-checklist.md +153 -0
  7. package/assets/references/iterations.md +73 -0
  8. package/assets/references/scrawls.md +483 -0
  9. package/assets/references/segments.md +79 -0
  10. package/assets/skills/exodus/SKILL.md +50 -0
  11. package/assets/skills/exodus-brand/SKILL.md +117 -0
  12. package/assets/skills/exodus-browse/SKILL.md +96 -0
  13. package/assets/skills/exodus-creative/SKILL.md +94 -0
  14. package/assets/skills/exodus-drive/SKILL.md +118 -0
  15. package/assets/skills/exodus-foundation/SKILL.md +170 -0
  16. package/assets/skills/exodus-genesis/SKILL.md +159 -0
  17. package/assets/skills/exodus-idea/SKILL.md +78 -0
  18. package/assets/skills/exodus-image/SKILL.md +224 -0
  19. package/assets/skills/exodus-meme/SKILL.md +183 -0
  20. package/assets/skills/exodus-primer/SKILL.md +145 -0
  21. package/assets/skills/exodus-strategist/SKILL.md +217 -0
  22. package/assets/skills/exodus-swipe/SKILL.md +85 -0
  23. package/assets/skills/exodus-template/SKILL.md +106 -0
  24. package/assets/skills/exodus-write/SKILL.md +157 -0
  25. package/assets/skills/genesis-bots/.env.example +12 -0
  26. package/assets/skills/genesis-bots/README.txt +29 -0
  27. package/assets/skills/genesis-bots/SKILL.md +219 -0
  28. package/assets/skills/genesis-bots/scripts/genesis-stream.mjs +307 -0
  29. package/assets/skills/genesis-bots/scripts/genesis-stream.py +220 -0
  30. package/dist/bin/exodus.d.ts +2 -0
  31. package/dist/bin/exodus.js +177 -0
  32. package/dist/commands/__tests__/genesis.hookgate.test.d.ts +1 -0
  33. package/dist/commands/__tests__/genesis.hookgate.test.js +12 -0
  34. package/dist/commands/brand.d.ts +24 -0
  35. package/dist/commands/brand.js +292 -0
  36. package/dist/commands/browse.d.ts +9 -0
  37. package/dist/commands/browse.js +132 -0
  38. package/dist/commands/creative.d.ts +2 -0
  39. package/dist/commands/creative.js +223 -0
  40. package/dist/commands/doctor.d.ts +21 -0
  41. package/dist/commands/doctor.js +590 -0
  42. package/dist/commands/drive.d.ts +2 -0
  43. package/dist/commands/drive.js +154 -0
  44. package/dist/commands/foundation.d.ts +2 -0
  45. package/dist/commands/foundation.js +468 -0
  46. package/dist/commands/genesis.d.ts +64 -0
  47. package/dist/commands/genesis.js +564 -0
  48. package/dist/commands/idea.d.ts +53 -0
  49. package/dist/commands/idea.js +333 -0
  50. package/dist/commands/image.d.ts +2 -0
  51. package/dist/commands/image.js +249 -0
  52. package/dist/commands/init.d.ts +13 -0
  53. package/dist/commands/init.js +70 -0
  54. package/dist/commands/keys.d.ts +2 -0
  55. package/dist/commands/keys.js +101 -0
  56. package/dist/commands/meme.d.ts +12 -0
  57. package/dist/commands/meme.js +272 -0
  58. package/dist/commands/migrate.d.ts +3 -0
  59. package/dist/commands/migrate.js +158 -0
  60. package/dist/commands/primer.d.ts +2 -0
  61. package/dist/commands/primer.js +540 -0
  62. package/dist/commands/read-doc.d.ts +3 -0
  63. package/dist/commands/read-doc.js +62 -0
  64. package/dist/commands/status.d.ts +6 -0
  65. package/dist/commands/status.js +205 -0
  66. package/dist/commands/swipe.d.ts +2 -0
  67. package/dist/commands/swipe.js +398 -0
  68. package/dist/commands/template.d.ts +2 -0
  69. package/dist/commands/template.js +225 -0
  70. package/dist/commands/whoami.d.ts +2 -0
  71. package/dist/commands/whoami.js +51 -0
  72. package/dist/lib/ad-library-write.d.ts +15 -0
  73. package/dist/lib/ad-library-write.js +67 -0
  74. package/dist/lib/assets.d.ts +3 -0
  75. package/dist/lib/assets.js +23 -0
  76. package/dist/lib/backend-hint.d.ts +2 -0
  77. package/dist/lib/backend-hint.js +22 -0
  78. package/dist/lib/cc-command.d.ts +1 -0
  79. package/dist/lib/cc-command.js +13 -0
  80. package/dist/lib/client.d.ts +23 -0
  81. package/dist/lib/client.js +213 -0
  82. package/dist/lib/format.d.ts +8 -0
  83. package/dist/lib/format.js +172 -0
  84. package/dist/lib/intel-format.d.ts +1 -0
  85. package/dist/lib/intel-format.js +38 -0
  86. package/dist/lib/keys-sync.d.ts +16 -0
  87. package/dist/lib/keys-sync.js +88 -0
  88. package/dist/lib/layout.d.ts +38 -0
  89. package/dist/lib/layout.js +153 -0
  90. package/dist/lib/load-env.d.ts +1 -0
  91. package/dist/lib/load-env.js +47 -0
  92. package/dist/lib/pixar-orchestrator-types.d.ts +54 -0
  93. package/dist/lib/pixar-orchestrator-types.js +1 -0
  94. package/dist/lib/poll.d.ts +14 -0
  95. package/dist/lib/poll.js +32 -0
  96. package/dist/lib/preflight.d.ts +18 -0
  97. package/dist/lib/preflight.js +56 -0
  98. package/dist/lib/prompts.d.ts +9 -0
  99. package/dist/lib/prompts.js +99 -0
  100. package/dist/lib/reel-write.d.ts +14 -0
  101. package/dist/lib/reel-write.js +59 -0
  102. package/dist/lib/release.d.ts +4 -0
  103. package/dist/lib/release.js +81 -0
  104. package/dist/lib/scaffold.d.ts +8 -0
  105. package/dist/lib/scaffold.js +67 -0
  106. package/dist/lib/scout-hydrate.d.ts +1 -0
  107. package/dist/lib/scout-hydrate.js +10 -0
  108. package/dist/lib/state.d.ts +6 -0
  109. package/dist/lib/state.js +49 -0
  110. package/dist/lib/template-constants.d.ts +4 -0
  111. package/dist/lib/template-constants.js +85 -0
  112. package/dist/lib/version.d.ts +1 -0
  113. package/dist/lib/version.js +19 -0
  114. package/package.json +30 -0
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Genesis API streaming helper (Node) — feature-parity port of genesis-stream.py.
4
+ *
5
+ * Usage:
6
+ * node genesis-stream.mjs <bot-slug> <prompt-file> <output-file> [temperature]
7
+ *
8
+ * Reads the prompt from <prompt-file>, calls the Genesis chat/completions endpoint
9
+ * with streaming on, accumulates the SSE stream, and writes the full text to
10
+ * <output-file>. Streaming is used because long generations would otherwise hit
11
+ * proxy read-timeouts; the server sends SSE heartbeats to keep the connection alive.
12
+ *
13
+ * Credentials are read from a local .env automatically (see loadEnv). Real shell
14
+ * env vars take precedence. Dependency-free — Node built-ins only.
15
+ */
16
+ import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
17
+ import { dirname, join, basename } from "node:path";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+
20
+ // ── Pure logic (unit-tested) ───────────────────────────────────────
21
+
22
+ export function parseEnvText(text) {
23
+ const out = {};
24
+ for (let line of text.split(/\r?\n/)) {
25
+ line = line.trim();
26
+ if (!line || line.startsWith("#") || !line.includes("=")) continue;
27
+ const i = line.indexOf("=");
28
+ const key = line.slice(0, i).trim();
29
+ let val = line.slice(i + 1).trim();
30
+ if (
31
+ (val.startsWith('"') && val.endsWith('"')) ||
32
+ (val.startsWith("'") && val.endsWith("'"))
33
+ ) {
34
+ val = val.slice(1, -1);
35
+ }
36
+ out[key] = val;
37
+ }
38
+ return out;
39
+ }
40
+
41
+ // Bucket candidate keys by prefix (sk-or- = OpenRouter, else Anthropic),
42
+ // first match wins — regardless of which env var they came from.
43
+ export function classifyKeys(candidates) {
44
+ const present = candidates.filter((k) => k);
45
+ const anthropicKey = present.find((k) => !k.startsWith("sk-or-")) ?? null;
46
+ const openrouterKey = present.find((k) => k.startsWith("sk-or-")) ?? null;
47
+ return { anthropicKey, openrouterKey };
48
+ }
49
+
50
+ export function overrideModelOf(model) {
51
+ const at = model.indexOf("@");
52
+ return at === -1 ? "" : model.slice(at + 1);
53
+ }
54
+
55
+ // A non-Claude override (openai/gpt-5, google/…) can only run via OpenRouter.
56
+ // Claude ids are "claude-*" (no slash); "anthropic/…" is Claude-via-OpenRouter.
57
+ export function needsOpenRouter(overrideModel) {
58
+ return overrideModel.includes("/") && !overrideModel.startsWith("anthropic/");
59
+ }
60
+
61
+ export function selectProviderKey({ anthropicKey, openrouterKey }, overrideModel) {
62
+ if (needsOpenRouter(overrideModel)) {
63
+ if (!openrouterKey) {
64
+ return {
65
+ ok: false,
66
+ error:
67
+ `model '${overrideModel}' needs an OpenRouter key (sk-or-…), but your .env has ` +
68
+ `no OpenRouter key. Add one from openrouter.ai/keys (as OPENROUTER_API_KEY) — an ` +
69
+ `OpenRouter key serves every model. Or request a Claude model (claude-*) to use ` +
70
+ `your Anthropic key.`,
71
+ };
72
+ }
73
+ return { ok: true, providerKey: openrouterKey, routing: "OpenRouter" };
74
+ }
75
+ if (anthropicKey) return { ok: true, providerKey: anthropicKey, routing: "Anthropic" };
76
+ if (openrouterKey) return { ok: true, providerKey: openrouterKey, routing: "OpenRouter" };
77
+ return {
78
+ ok: false,
79
+ error:
80
+ "no provider key found. Add ANTHROPIC_API_KEY (sk-ant-…) or OPENROUTER_API_KEY " +
81
+ "(sk-or-…) to your .env — or run `npx @aicopycoders/exodus keys pull` to sync it from the dashboard.",
82
+ };
83
+ }
84
+
85
+ // Extract delta text / finish_reason / error from one parsed SSE chunk.
86
+ // Tolerates empty or absent `choices` arrays (final/usage-only chunks send
87
+ // `choices: []`, which is truthy in JS — the Python original leaned on
88
+ // empty-list-is-falsy, which does not port).
89
+ export function parseStreamChunk(chunk) {
90
+ if (chunk && chunk.error) {
91
+ const e = chunk.error;
92
+ return { error: `${e.type || "stream_error"}: ${e.message || JSON.stringify(e)}` };
93
+ }
94
+ const choice = ((chunk && chunk.choices) || [])[0] || {};
95
+ return {
96
+ content: (choice.delta || {}).content || "",
97
+ finishReason: choice.finish_reason || null,
98
+ };
99
+ }
100
+
101
+ export function normalizeBase(url) {
102
+ let base = url.replace(/\/+$/, "");
103
+ if (!base.endsWith("/api/v1")) base += "/api/v1";
104
+ return base;
105
+ }
106
+
107
+ // ── .env autoload (mirrors the Python loader) ──────────────────────
108
+ // Walk up from `start` to the project root (the dir holding `.claude/`) and
109
+ // return its `.env`, so one root .env serves a bundled install from any subdir.
110
+ function findProjectRootEnv(startFile) {
111
+ let dir = dirname(startFile);
112
+ while (true) {
113
+ const parent = dirname(dir);
114
+ if (parent === dir) return null; // reached fs root
115
+ dir = parent;
116
+ if (existsSync(join(dir, ".claude")) && statSync(join(dir, ".claude")).isDirectory()) {
117
+ return join(dir, ".env");
118
+ }
119
+ }
120
+ }
121
+
122
+ // Order (first to set a key wins; real shell env always wins): next to this
123
+ // script, the skill folder above it, the project root (.claude parent), the cwd.
124
+ function loadEnv() {
125
+ const here = dirname(fileURLToPath(import.meta.url));
126
+ const candidates = [join(here, ".env"), join(dirname(here), ".env")];
127
+ const rootEnv = findProjectRootEnv(fileURLToPath(import.meta.url));
128
+ if (rootEnv) candidates.push(rootEnv);
129
+ candidates.push(join(process.cwd(), ".env"));
130
+
131
+ const loaded = [];
132
+ for (const file of candidates) {
133
+ if (!existsSync(file) || !statSync(file).isFile()) continue;
134
+ loaded.push(file);
135
+ const parsed = parseEnvText(readFileSync(file, "utf8"));
136
+ for (const [k, v] of Object.entries(parsed)) {
137
+ if (process.env[k] === undefined) process.env[k] = v; // setdefault: shell env wins
138
+ }
139
+ }
140
+ return loaded;
141
+ }
142
+
143
+ // Apply one raw SSE line to the streaming accumulator. Returns the next state.
144
+ // `state` = { content, finishReason, sawDone, errorMsg, stop }.
145
+ // `stop` signals the caller to stop reading (saw [DONE] or an error).
146
+ export function applySSELine(rawLine, state) {
147
+ if (state.stop) return state;
148
+ const line = rawLine.trim();
149
+ if (!line.startsWith("data: ")) return state;
150
+ if (line === "data: [DONE]") return { ...state, sawDone: true, stop: true };
151
+ let chunk;
152
+ try {
153
+ chunk = JSON.parse(line.slice(6));
154
+ } catch {
155
+ return state;
156
+ }
157
+ const parsed = parseStreamChunk(chunk);
158
+ if (parsed.error) return { ...state, errorMsg: parsed.error, stop: true };
159
+ return {
160
+ ...state,
161
+ content: state.content + parsed.content,
162
+ finishReason: parsed.finishReason || state.finishReason,
163
+ };
164
+ }
165
+
166
+ // ── CLI ────────────────────────────────────────────────────────────
167
+
168
+ async function main() {
169
+ const loadedEnvFiles = loadEnv();
170
+ const args = process.argv.slice(2);
171
+ if (args.length < 3) {
172
+ console.error("usage: genesis-stream.mjs <bot-slug> <prompt-file> <output-file> [temperature]");
173
+ process.exit(2);
174
+ }
175
+ const [model, promptFile, outputFile] = args;
176
+
177
+ // Fix 2: loudly reject a non-numeric temperature arg.
178
+ let temperature = null;
179
+ if (args.length > 3) {
180
+ const raw = args[3];
181
+ const parsed = Number(raw);
182
+ if (Number.isNaN(parsed)) {
183
+ console.error(`ERROR: temperature must be a number (got '${raw}')`);
184
+ process.exit(2);
185
+ }
186
+ temperature = parsed;
187
+ }
188
+
189
+ const { anthropicKey, openrouterKey } = classifyKeys([
190
+ process.env.ANTHROPIC_API_KEY,
191
+ process.env.OPENROUTER_API_KEY,
192
+ process.env.GENESIS_PROVIDER_KEY,
193
+ ]);
194
+ const sel = selectProviderKey({ anthropicKey, openrouterKey }, overrideModelOf(model));
195
+ if (!sel.ok) {
196
+ console.error(`ERROR: ${sel.error}`);
197
+ process.exit(2);
198
+ }
199
+
200
+ // Transparency (stderr so it never mixes into the output file).
201
+ const envSrc = loadedEnvFiles.length ? loadedEnvFiles.join(", ") : "none found (shell env only)";
202
+ console.error(`[genesis] model '${model}' → ${sel.routing}; .env: ${envSrc}`);
203
+
204
+ if (!process.env.GENESIS_BASE_URL) {
205
+ console.error("ERROR: GENESIS_BASE_URL is not set (.env or shell env).");
206
+ process.exit(2);
207
+ }
208
+ if (!process.env.GENESIS_API_KEY) {
209
+ console.error("ERROR: GENESIS_API_KEY is not set (.env or shell env).");
210
+ process.exit(2);
211
+ }
212
+
213
+ const prompt = readFileSync(promptFile, "utf8");
214
+ const base = normalizeBase(process.env.GENESIS_BASE_URL);
215
+ const payload = {
216
+ model,
217
+ messages: [{ role: "user", content: prompt }],
218
+ stream: true,
219
+ };
220
+ if (temperature !== null && !Number.isNaN(temperature)) payload.temperature = temperature;
221
+
222
+ // Fail LOUDLY. The server signals errors three ways and a silent zero-char
223
+ // "success" is worse than any: (1) non-200 before the stream, (2) a
224
+ // data:{"error":…} SSE event mid-stream, (3) a dropped connection — caught
225
+ // because a healthy stream ends with [DONE].
226
+ let content = "";
227
+ let finishReason = null;
228
+ let sawDone = false;
229
+ let errorMsg = null;
230
+
231
+ try {
232
+ const res = await fetch(`${base}/chat/completions`, {
233
+ method: "POST",
234
+ headers: {
235
+ Authorization: `Bearer ${process.env.GENESIS_API_KEY}`,
236
+ "X-Provider-Key": sel.providerKey,
237
+ "Content-Type": "application/json",
238
+ },
239
+ body: JSON.stringify(payload),
240
+ });
241
+
242
+ if (!res.ok || !res.body) {
243
+ let detail = "";
244
+ try {
245
+ const j = JSON.parse(await res.text());
246
+ detail = j?.error?.message ?? "";
247
+ } catch {
248
+ /* non-JSON body */
249
+ }
250
+ errorMsg = `HTTP ${res.status}: ${detail || res.statusText}`;
251
+ } else {
252
+ const reader = res.body.getReader();
253
+ const decoder = new TextDecoder();
254
+ let buffer = "";
255
+ let state = { content: "", finishReason: null, sawDone: false, errorMsg: null, stop: false };
256
+ while (true) {
257
+ const { done, value } = await reader.read();
258
+ if (done) {
259
+ // Flush any remainder that arrived without a trailing newline.
260
+ if (buffer.trim()) {
261
+ state = applySSELine(buffer, state);
262
+ }
263
+ break;
264
+ }
265
+ buffer += decoder.decode(value, { stream: true });
266
+ const lines = buffer.split("\n");
267
+ buffer = lines.pop() ?? "";
268
+ for (const raw of lines) {
269
+ state = applySSELine(raw, state);
270
+ if (state.stop) break;
271
+ }
272
+ if (state.stop) break;
273
+ }
274
+ // Map final state back to the existing post-loop variables.
275
+ content = state.content;
276
+ finishReason = state.finishReason;
277
+ sawDone = state.sawDone;
278
+ errorMsg = state.errorMsg;
279
+ }
280
+ } catch (e) {
281
+ errorMsg = e instanceof Error ? e.message : String(e);
282
+ }
283
+
284
+ if (errorMsg === null && !sawDone) {
285
+ errorMsg = "stream ended early (no [DONE] sentinel) — output is likely truncated, retry";
286
+ }
287
+ if (errorMsg === null && !content) {
288
+ errorMsg = "stream completed but produced no content — retry, and check your keys if it persists";
289
+ }
290
+
291
+ // Always write whatever arrived — partial output can still help debugging.
292
+ writeFileSync(outputFile, content);
293
+
294
+ if (errorMsg) {
295
+ console.error(`ERROR: ${errorMsg}`);
296
+ if (content) {
297
+ console.error(`(partial output, ${content.length} chars, saved to ${basename(outputFile)})`);
298
+ }
299
+ process.exit(1);
300
+ }
301
+ console.log(`Done: ${basename(outputFile)} (${content.length} chars, finish_reason=${finishReason})`);
302
+ }
303
+
304
+ // Run as CLI only when invoked directly (not when imported by tests).
305
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
306
+ main();
307
+ }
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Genesis API streaming helper.
4
+
5
+ Usage:
6
+ python3 genesis-stream.py <bot-slug> <prompt-file> <output-file> [temperature]
7
+
8
+ Reads the prompt from <prompt-file>, calls the Genesis chat/completions endpoint with
9
+ streaming on (the API default), accumulates the SSE stream, and writes the full text to
10
+ <output-file>. Streaming is used because long generations would otherwise hit proxy
11
+ read-timeouts; the server sends SSE heartbeats to keep the connection alive.
12
+
13
+ Credentials are read from a local .env file automatically (see load_env below) — copy
14
+ .env.example to .env and fill in your keys. Real shell env vars, if set, take precedence.
15
+
16
+ Required values (in .env or the environment) — names match the member portal:
17
+ GENESIS_BASE_URL API base. Default https://gas.copycoders.ai/api/v1
18
+ (works with or without the /api/v1 suffix).
19
+ GENESIS_API_KEY your Genesis access key (gen_...), sent as Authorization: Bearer
20
+ Provider key your LLM key, sent as X-Provider-Key — you pay usage.
21
+ ANTHROPIC_API_KEY (sk-ant-...) serves Claude models;
22
+ OPENROUTER_API_KEY (sk-or-...) serves everything (Claude,
23
+ GPT-5, Gemini, ...). You may set BOTH — this helper auto-picks
24
+ the right one for the requested model: a non-Claude override
25
+ (e.g. @openai/gpt-5) uses the OpenRouter key; Claude or no
26
+ override prefers the Anthropic key (direct, cheaper), falling
27
+ back to OpenRouter. Keys are classified by prefix, so one
28
+ pasted into the "wrong" variable still routes correctly.
29
+ GENESIS_PROVIDER_KEY (legacy alias) is honored too. These are
30
+ the names `exodus keys pull` writes.
31
+ """
32
+ import sys, json, os, urllib.request, urllib.error
33
+ from pathlib import Path
34
+
35
+
36
+ def find_project_root_env(start):
37
+ """Walk up from `start` to the project root (the dir holding `.claude/`) and
38
+ return its `.env`. This is how one root .env serves the bundled Exodus install
39
+ no matter which subdir the agent runs from. Returns None if no .claude root."""
40
+ p = Path(start).resolve()
41
+ for parent in p.parents:
42
+ if (parent / ".claude").is_dir():
43
+ return parent / ".env"
44
+ return None
45
+
46
+
47
+ def load_env():
48
+ """Auto-load a .env (KEY=VALUE per line) so users never touch shell env vars.
49
+
50
+ Order (first to set a key wins, real shell env always wins via setdefault):
51
+ next to this script, the skill folder above it, the project root (.claude
52
+ parent — the bundled Exodus install's single root .env), then the cwd.
53
+ No external dependencies.
54
+ """
55
+ here = Path(__file__).resolve().parent
56
+ candidates = [here / ".env", here.parent / ".env"]
57
+ root_env = find_project_root_env(__file__)
58
+ if root_env is not None:
59
+ candidates.append(root_env)
60
+ candidates.append(Path.cwd() / ".env")
61
+ loaded = []
62
+ for candidate in candidates:
63
+ if not candidate.is_file():
64
+ continue
65
+ loaded.append(str(candidate))
66
+ for line in candidate.read_text().splitlines():
67
+ line = line.strip()
68
+ if not line or line.startswith("#") or "=" not in line:
69
+ continue
70
+ key, _, val = line.partition("=")
71
+ key = key.strip()
72
+ val = val.strip().strip('"').strip("'")
73
+ os.environ.setdefault(key, val)
74
+ return loaded
75
+
76
+
77
+ loaded_env_files = load_env()
78
+
79
+ if len(sys.argv) < 4:
80
+ print("usage: genesis-stream.py <bot-slug> <prompt-file> <output-file> [temperature]")
81
+ sys.exit(2)
82
+
83
+ model = sys.argv[1]
84
+ prompt_file = sys.argv[2]
85
+ output_file = sys.argv[3]
86
+ # Temperature is optional. Omit it when not passed so the bot's own configured
87
+ # default applies (don't force a value over it). The server also drops temperature
88
+ # automatically for models that don't accept it (Opus 4.7+/Fable), so passing one
89
+ # there is harmless — it's simply ignored.
90
+ temperature = float(sys.argv[4]) if len(sys.argv) > 4 else None
91
+
92
+ # Model-aware provider-key selection. The model field may carry a per-request
93
+ # override "<bot-slug>@<model>"; the part after the first "@" decides which key we
94
+ # need. Classify every key the user set by prefix (sk-or- = OpenRouter, else
95
+ # Anthropic) regardless of which env var it landed in, so a key pasted into the
96
+ # "wrong" variable still routes correctly.
97
+ _candidate_keys = [
98
+ os.environ.get("ANTHROPIC_API_KEY"),
99
+ os.environ.get("OPENROUTER_API_KEY"),
100
+ os.environ.get("GENESIS_PROVIDER_KEY"),
101
+ ]
102
+ anthropic_key = next((k for k in _candidate_keys if k and not k.startswith("sk-or-")), None)
103
+ openrouter_key = next((k for k in _candidate_keys if k and k.startswith("sk-or-")), None)
104
+
105
+ override_model = model.split("@", 1)[1] if "@" in model else ""
106
+ # A non-Claude override (openai/gpt-5, google/...) can only run via OpenRouter.
107
+ # Claude ids are "claude-*" (no slash); "anthropic/..." is Claude-via-OpenRouter.
108
+ needs_openrouter = "/" in override_model and not override_model.startswith("anthropic/")
109
+
110
+ if needs_openrouter:
111
+ provider_key, routing = openrouter_key, "OpenRouter"
112
+ if not provider_key:
113
+ print(
114
+ f"ERROR: model '{override_model}' needs an OpenRouter key (sk-or-…), but your "
115
+ f".env has no OpenRouter key. Add one from openrouter.ai/keys (as "
116
+ f"OPENROUTER_API_KEY) — an OpenRouter key serves every model. Or request a "
117
+ f"Claude model (claude-*) to use your Anthropic key.",
118
+ file=sys.stderr,
119
+ )
120
+ sys.exit(2)
121
+ elif anthropic_key:
122
+ provider_key, routing = anthropic_key, "Anthropic"
123
+ elif openrouter_key:
124
+ provider_key, routing = openrouter_key, "OpenRouter"
125
+ else:
126
+ print(
127
+ "ERROR: no provider key found. Add ANTHROPIC_API_KEY (sk-ant-…) or "
128
+ "OPENROUTER_API_KEY (sk-or-…) to your .env — or run `npx @aicopycoders/exodus keys pull` "
129
+ "to sync it from the dashboard.",
130
+ file=sys.stderr,
131
+ )
132
+ sys.exit(2)
133
+
134
+ # Transparency: which .env was loaded and which provider we're using — surfaces the
135
+ # "I edited the wrong .env" / "why isn't my OpenRouter key used" confusion instantly.
136
+ # Stderr so it never mixes into the generated output file.
137
+ _env_src = ", ".join(loaded_env_files) if loaded_env_files else "none found (shell env only)"
138
+ print(f"[genesis] model '{model}' → {routing}; .env: {_env_src}", file=sys.stderr)
139
+
140
+ with open(prompt_file) as f:
141
+ prompt = f.read()
142
+
143
+ # Accept the base URL with or without the /api/v1 suffix, so pasting the value
144
+ # straight from the portal (https://gas.copycoders.ai/api/v1) also works.
145
+ base = os.environ["GENESIS_BASE_URL"].rstrip("/")
146
+ if not base.endswith("/api/v1"):
147
+ base = base + "/api/v1"
148
+
149
+ payload_obj = {
150
+ "model": model,
151
+ "messages": [{"role": "user", "content": prompt}],
152
+ "stream": True,
153
+ }
154
+ if temperature is not None:
155
+ payload_obj["temperature"] = temperature
156
+ payload = json.dumps(payload_obj).encode()
157
+
158
+ req = urllib.request.Request(
159
+ f"{base}/chat/completions",
160
+ data=payload,
161
+ headers={
162
+ "Authorization": f"Bearer {os.environ['GENESIS_API_KEY']}",
163
+ "X-Provider-Key": provider_key,
164
+ "Content-Type": "application/json",
165
+ },
166
+ )
167
+
168
+ # Fail LOUDLY. The server sends errors three ways and a silent zero-char "success"
169
+ # is worse than any of them: (1) non-200 JSON before the stream starts, (2) a
170
+ # `data: {"error": ...}` SSE event mid-stream, (3) a dropped connection — detected
171
+ # because a healthy stream always ends with finish_reason then `data: [DONE]`.
172
+ content = ""
173
+ finish_reason = None
174
+ saw_done = False
175
+ error_msg = None
176
+ try:
177
+ with urllib.request.urlopen(req, timeout=600) as resp:
178
+ for raw_line in resp:
179
+ line = raw_line.decode().strip()
180
+ if not line.startswith("data: "):
181
+ continue
182
+ if line == "data: [DONE]":
183
+ saw_done = True
184
+ break
185
+ try:
186
+ chunk = json.loads(line[6:])
187
+ except Exception:
188
+ continue
189
+ if "error" in chunk:
190
+ err = chunk["error"]
191
+ error_msg = f"{err.get('type', 'stream_error')}: {err.get('message', json.dumps(err))}"
192
+ break
193
+ choice = (chunk.get("choices") or [{}])[0]
194
+ content += (choice.get("delta") or {}).get("content", "")
195
+ finish_reason = choice.get("finish_reason") or finish_reason
196
+ except urllib.error.HTTPError as e:
197
+ detail = ""
198
+ try:
199
+ detail = json.loads(e.read().decode()).get("error", {}).get("message", "")
200
+ except Exception:
201
+ pass
202
+ error_msg = f"HTTP {e.code}: {detail or e.reason}"
203
+ except Exception as e:
204
+ error_msg = str(e)
205
+
206
+ if error_msg is None and not saw_done:
207
+ error_msg = "stream ended early (no [DONE] sentinel) — output is likely truncated, retry"
208
+ if error_msg is None and not content:
209
+ error_msg = "stream completed but produced no content — retry, and check your keys if it persists"
210
+
211
+ # Always write whatever arrived — partial output can still be useful for debugging.
212
+ with open(output_file, "w") as f:
213
+ f.write(content)
214
+
215
+ if error_msg:
216
+ print(f"ERROR: {error_msg}", file=sys.stderr)
217
+ if content:
218
+ print(f"(partial output, {len(content)} chars, saved to {os.path.basename(output_file)})", file=sys.stderr)
219
+ sys.exit(1)
220
+ print(f"Done: {os.path.basename(output_file)} ({len(content)} chars, finish_reason={finish_reason})")
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};