@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.
- package/assets/references/awareness-framework.md +156 -0
- package/assets/references/cast-video.md +165 -0
- package/assets/references/copy-blocks.md +138 -0
- package/assets/references/creative-strategy.md +990 -0
- package/assets/references/editing-rules.md +81 -0
- package/assets/references/hook-quality-checklist.md +153 -0
- package/assets/references/iterations.md +73 -0
- package/assets/references/scrawls.md +483 -0
- package/assets/references/segments.md +79 -0
- package/assets/skills/exodus/SKILL.md +50 -0
- package/assets/skills/exodus-brand/SKILL.md +117 -0
- package/assets/skills/exodus-browse/SKILL.md +96 -0
- package/assets/skills/exodus-creative/SKILL.md +94 -0
- package/assets/skills/exodus-drive/SKILL.md +118 -0
- package/assets/skills/exodus-foundation/SKILL.md +170 -0
- package/assets/skills/exodus-genesis/SKILL.md +159 -0
- package/assets/skills/exodus-idea/SKILL.md +78 -0
- package/assets/skills/exodus-image/SKILL.md +224 -0
- package/assets/skills/exodus-meme/SKILL.md +183 -0
- package/assets/skills/exodus-primer/SKILL.md +145 -0
- package/assets/skills/exodus-strategist/SKILL.md +217 -0
- package/assets/skills/exodus-swipe/SKILL.md +85 -0
- package/assets/skills/exodus-template/SKILL.md +106 -0
- package/assets/skills/exodus-write/SKILL.md +157 -0
- package/assets/skills/genesis-bots/.env.example +12 -0
- package/assets/skills/genesis-bots/README.txt +29 -0
- package/assets/skills/genesis-bots/SKILL.md +219 -0
- package/assets/skills/genesis-bots/scripts/genesis-stream.mjs +307 -0
- package/assets/skills/genesis-bots/scripts/genesis-stream.py +220 -0
- package/dist/bin/exodus.d.ts +2 -0
- package/dist/bin/exodus.js +177 -0
- package/dist/commands/__tests__/genesis.hookgate.test.d.ts +1 -0
- package/dist/commands/__tests__/genesis.hookgate.test.js +12 -0
- package/dist/commands/brand.d.ts +24 -0
- package/dist/commands/brand.js +292 -0
- package/dist/commands/browse.d.ts +9 -0
- package/dist/commands/browse.js +132 -0
- package/dist/commands/creative.d.ts +2 -0
- package/dist/commands/creative.js +223 -0
- package/dist/commands/doctor.d.ts +21 -0
- package/dist/commands/doctor.js +590 -0
- package/dist/commands/drive.d.ts +2 -0
- package/dist/commands/drive.js +154 -0
- package/dist/commands/foundation.d.ts +2 -0
- package/dist/commands/foundation.js +468 -0
- package/dist/commands/genesis.d.ts +64 -0
- package/dist/commands/genesis.js +564 -0
- package/dist/commands/idea.d.ts +53 -0
- package/dist/commands/idea.js +333 -0
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +249 -0
- package/dist/commands/init.d.ts +13 -0
- package/dist/commands/init.js +70 -0
- package/dist/commands/keys.d.ts +2 -0
- package/dist/commands/keys.js +101 -0
- package/dist/commands/meme.d.ts +12 -0
- package/dist/commands/meme.js +272 -0
- package/dist/commands/migrate.d.ts +3 -0
- package/dist/commands/migrate.js +158 -0
- package/dist/commands/primer.d.ts +2 -0
- package/dist/commands/primer.js +540 -0
- package/dist/commands/read-doc.d.ts +3 -0
- package/dist/commands/read-doc.js +62 -0
- package/dist/commands/status.d.ts +6 -0
- package/dist/commands/status.js +205 -0
- package/dist/commands/swipe.d.ts +2 -0
- package/dist/commands/swipe.js +398 -0
- package/dist/commands/template.d.ts +2 -0
- package/dist/commands/template.js +225 -0
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.js +51 -0
- package/dist/lib/ad-library-write.d.ts +15 -0
- package/dist/lib/ad-library-write.js +67 -0
- package/dist/lib/assets.d.ts +3 -0
- package/dist/lib/assets.js +23 -0
- package/dist/lib/backend-hint.d.ts +2 -0
- package/dist/lib/backend-hint.js +22 -0
- package/dist/lib/cc-command.d.ts +1 -0
- package/dist/lib/cc-command.js +13 -0
- package/dist/lib/client.d.ts +23 -0
- package/dist/lib/client.js +213 -0
- package/dist/lib/format.d.ts +8 -0
- package/dist/lib/format.js +172 -0
- package/dist/lib/intel-format.d.ts +1 -0
- package/dist/lib/intel-format.js +38 -0
- package/dist/lib/keys-sync.d.ts +16 -0
- package/dist/lib/keys-sync.js +88 -0
- package/dist/lib/layout.d.ts +38 -0
- package/dist/lib/layout.js +153 -0
- package/dist/lib/load-env.d.ts +1 -0
- package/dist/lib/load-env.js +47 -0
- package/dist/lib/pixar-orchestrator-types.d.ts +54 -0
- package/dist/lib/pixar-orchestrator-types.js +1 -0
- package/dist/lib/poll.d.ts +14 -0
- package/dist/lib/poll.js +32 -0
- package/dist/lib/preflight.d.ts +18 -0
- package/dist/lib/preflight.js +56 -0
- package/dist/lib/prompts.d.ts +9 -0
- package/dist/lib/prompts.js +99 -0
- package/dist/lib/reel-write.d.ts +14 -0
- package/dist/lib/reel-write.js +59 -0
- package/dist/lib/release.d.ts +4 -0
- package/dist/lib/release.js +81 -0
- package/dist/lib/scaffold.d.ts +8 -0
- package/dist/lib/scaffold.js +67 -0
- package/dist/lib/scout-hydrate.d.ts +1 -0
- package/dist/lib/scout-hydrate.js +10 -0
- package/dist/lib/state.d.ts +6 -0
- package/dist/lib/state.js +49 -0
- package/dist/lib/template-constants.d.ts +4 -0
- package/dist/lib/template-constants.js +85 -0
- package/dist/lib/version.d.ts +1 -0
- package/dist/lib/version.js +19 -0
- 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})")
|