dreamlayer 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DreamLayer AI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ DreamLayer CLI
2
+ Copyright (c) 2026 DreamLayer AI
3
+
4
+ This package is a thin client. It sends requests to the DreamLayer Agent API at
5
+ https://api.dreamlayer.io and returns what that service replies. The service itself
6
+ is proprietary and is not included here.
7
+
8
+ Nothing in this package inspects, stores, or transmits your prompts or images anywhere
9
+ other than the DreamLayer Agent API, using the key you supply in DREAMLAYER_API_KEY.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # dreamlayer
2
+
3
+ Generate and edit images from your terminal, over local files, with one API key.
4
+
5
+ ```bash
6
+ npm install -g dreamlayer
7
+ export DREAMLAYER_API_KEY="dlr_live_your_key"
8
+
9
+ dreamlayer generate "A glass greenhouse at dusk" --out greenhouse.png
10
+ ```
11
+
12
+ Get a key at [platform.dreamlayer.io](https://platform.dreamlayer.io). A new account
13
+ starts at zero credits, and each finished image costs one.
14
+
15
+ > **Not yet publishable.** This package sends an `operation` field that requires
16
+ > the gateway build adding it to `ExecuteRequest`. Against the currently deployed
17
+ > API every call returns `422 extra_forbidden`. Deploy that build first.
18
+
19
+ ## Commands
20
+
21
+ ```bash
22
+ dreamlayer generate <prompt> [--aspect 16:9] [--out file.png]
23
+ dreamlayer edit <image> <prompt> [--out file.png]
24
+ dreamlayer cutout <image> [--out file.png] # background removal
25
+ dreamlayer upscale <image> [--out file.png] # 2x
26
+ dreamlayer answer <conversation-id> <text> [--image file.png]
27
+ dreamlayer status <execution-id>
28
+ dreamlayer capabilities # spends nothing
29
+ ```
30
+
31
+ `cutout` and `upscale` name their operation rather than hoping a sentence is read the
32
+ way you meant, so they run a dedicated chain and never stop to ask a question.
33
+
34
+ `upscale` doubles each side and finished images are capped at 4096 per side, so the
35
+ longest side of your input must be 2048 or less. Anything larger is refused before it
36
+ costs you a credit.
37
+
38
+ ## It composes
39
+
40
+ **stdout is the result, stderr is the narration.** On success stdout is the file path
41
+ and nothing else, so this works:
42
+
43
+ ```bash
44
+ open "$(dreamlayer generate 'a fox logo')"
45
+ ```
46
+
47
+ **Exit codes mean something**, so a script can branch instead of grepping:
48
+
49
+ | Code | Meaning |
50
+ |---|---|
51
+ | 0 | Success |
52
+ | 1 | Usage error |
53
+ | 2 | Auth or account problem |
54
+ | 3 | Out of credits |
55
+ | 4 | Request rejected, will fail identically until you change it |
56
+ | 5 | Temporary, worth retrying |
57
+ | 6 | It asked a question instead of producing an image |
58
+
59
+ ```bash
60
+ for f in shots/*.png; do dreamlayer cutout "$f" --out "cut/$(basename "$f")" || break; done
61
+ ```
62
+
63
+ **`--json`** gives machine-readable output on stdout, carrying job state, execution ids,
64
+ and the written path. It deliberately excludes your prompt, your images, and your key,
65
+ so it is safe to paste into a bug report.
66
+
67
+ ## Retries are safe if you reuse the key
68
+
69
+ An idempotency key is generated per run. After an uncertain response, pass the same one
70
+ back and the original result replays instead of paying twice:
71
+
72
+ ```bash
73
+ dreamlayer generate "a fox logo" --idempotency-key fox-001
74
+ ```
75
+
76
+ ## A question is not a failure
77
+
78
+ An edit-shaped prompt with no image exits 6 and asks for one:
79
+
80
+ ```bash
81
+ dreamlayer generate "remove the background"
82
+ # Which image should I use? Upload or attach one, then respond.
83
+ # dreamlayer answer <id> "your answer" --image <file>
84
+ ```
85
+
86
+ Answer it with the image attached. Words alone are refused, because the question is
87
+ asking for a picture, not a clarification.
88
+
89
+ Naming an operation avoids the round trip entirely, which is why `cutout`, `upscale`,
90
+ `edit`, and `generate` all do.
91
+
92
+ ## Requirements
93
+
94
+ Node.js 22.12 or later.
95
+
96
+ ## License
97
+
98
+ MIT. See LICENSE and NOTICE.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * DreamLayer CLI.
4
+ *
5
+ * Generate and edit images from a terminal, over local files. The API is the same one
6
+ * the MCP server and the web app use, and it spends from the same credit balance.
7
+ *
8
+ * Exit codes are meaningful, so this composes in a script:
9
+ * 0 success
10
+ * 1 usage error
11
+ * 2 authentication or account problem (401, 403)
12
+ * 3 out of credits (402)
13
+ * 4 the request was rejected (409, 422)
14
+ * 5 temporary, worth retrying (429, 5xx)
15
+ * 6 the run ended asking a question instead of producing an image
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+ import { readFile, writeFile } from "node:fs/promises";
19
+ import path from "node:path";
20
+ import { ApiError, ManagedClient, StreamIdleError, } from "./client.js";
21
+ import { Progress, consume } from "./render.js";
22
+ const USAGE = `dreamlayer - generate and edit images from your terminal
23
+
24
+ USAGE
25
+ dreamlayer generate <prompt> [--aspect <ratio>] [--out <file>]
26
+ dreamlayer edit <image> <prompt> [--out <file>]
27
+ dreamlayer cutout <image> [--out <file>]
28
+ dreamlayer upscale <image> [--out <file>]
29
+ dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
30
+ dreamlayer status <execution-id>
31
+ dreamlayer capabilities
32
+
33
+ OPTIONS
34
+ --out <file> Where to write the image. Default: dreamlayer-<n>.png
35
+ --image <file> Attach an image when answering a question that asks for one
36
+ --aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
37
+ --json Machine-readable output on stdout
38
+ --quiet No progress on stderr
39
+ --idempotency-key <key> Reuse to retry safely after an uncertain response
40
+
41
+ ENVIRONMENT
42
+ DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
43
+ DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
44
+
45
+ Every finished image costs one credit. A new account starts at zero.
46
+ `;
47
+ class UsageError extends Error {
48
+ }
49
+ function parseOptions(argv) {
50
+ const positional = [];
51
+ const options = {
52
+ out: null,
53
+ image: null,
54
+ aspect: "1:1",
55
+ json: false,
56
+ quiet: false,
57
+ idempotencyKey: null,
58
+ };
59
+ for (let i = 0; i < argv.length; i += 1) {
60
+ const token = argv[i];
61
+ if (token === "--json")
62
+ options.json = true;
63
+ else if (token === "--quiet")
64
+ options.quiet = true;
65
+ else if (token === "--out" || token === "-o") {
66
+ const value = argv[++i];
67
+ if (!value)
68
+ throw new UsageError("--out needs a file path");
69
+ options.out = value;
70
+ }
71
+ else if (token === "--image") {
72
+ const value = argv[++i];
73
+ if (!value)
74
+ throw new UsageError("--image needs a file path");
75
+ options.image = value;
76
+ }
77
+ else if (token === "--aspect") {
78
+ const value = argv[++i];
79
+ if (!value)
80
+ throw new UsageError("--aspect needs a ratio");
81
+ options.aspect = value;
82
+ }
83
+ else if (token === "--idempotency-key") {
84
+ const value = argv[++i];
85
+ if (!value)
86
+ throw new UsageError("--idempotency-key needs a value");
87
+ options.idempotencyKey = value;
88
+ }
89
+ else if (token !== undefined && token.startsWith("-")) {
90
+ throw new UsageError(`unknown option ${token}`);
91
+ }
92
+ else if (token !== undefined) {
93
+ positional.push(token);
94
+ }
95
+ }
96
+ return { positional, options };
97
+ }
98
+ function client() {
99
+ const key = (process.env.DREAMLAYER_API_KEY ?? "").trim();
100
+ if (!key) {
101
+ throw new UsageError("DREAMLAYER_API_KEY is not set.\n" +
102
+ " export DREAMLAYER_API_KEY=dlr_live_...\n" +
103
+ " Get a key at https://platform.dreamlayer.io");
104
+ }
105
+ return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim());
106
+ }
107
+ /** Upload a local file and return its asset id, with size and type checked here first. */
108
+ async function upload(api, file) {
109
+ const resolved = path.resolve(file);
110
+ const extension = path.extname(resolved).toLowerCase();
111
+ if (![".png", ".jpg", ".jpeg", ".webp"].includes(extension)) {
112
+ throw new UsageError(`${file} is not a PNG, JPEG, or WEBP`);
113
+ }
114
+ let bytes;
115
+ try {
116
+ bytes = await readFile(resolved);
117
+ }
118
+ catch {
119
+ throw new UsageError(`cannot read ${file}`);
120
+ }
121
+ if (bytes.byteLength > 20 * 1024 * 1024) {
122
+ throw new UsageError(`${file} is ${Math.round(bytes.byteLength / 1024 / 1024)} MB; the limit is 20 MB`);
123
+ }
124
+ const asset = await api.uploadInput(new Blob([new Uint8Array(bytes)]), path.basename(resolved));
125
+ return asset.input_asset_id;
126
+ }
127
+ function defaultOut() {
128
+ return `dreamlayer-${Date.now()}.png`;
129
+ }
130
+ async function run(api, input, options) {
131
+ const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
132
+ const idempotencyKey = options.idempotencyKey ?? randomUUID();
133
+ const outcome = await consume(api.execute(input, { idempotencyKey }), progress);
134
+ if (outcome.question) {
135
+ progress.stop();
136
+ if (options.json) {
137
+ process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
138
+ }
139
+ else {
140
+ process.stderr.write(`\nDreamLayer needs one more thing:\n ${outcome.question.text}\n\n`);
141
+ // The server's needs_input question always asks for an image, so point at the
142
+ // flag that can supply one rather than the bare form that will 422.
143
+ const wantsImage = /image/i.test(outcome.question.text);
144
+ process.stderr.write(`Answer it with:\n dreamlayer answer ${outcome.conversation_id} "your answer"` +
145
+ `${wantsImage ? " --image <file>" : ""}\n`);
146
+ }
147
+ return 6;
148
+ }
149
+ if (outcome.status !== "completed" || !outcome.asset) {
150
+ progress.stop("Failed");
151
+ if (options.json)
152
+ process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
153
+ else
154
+ process.stderr.write(`Run ended as ${outcome.status}. No credit was settled.\n`);
155
+ return 5;
156
+ }
157
+ progress.set("Downloading");
158
+ const bytes = await api.download(outcome.asset.download_url);
159
+ const target = options.out ?? defaultOut();
160
+ await writeFile(target, bytes);
161
+ progress.stop();
162
+ if (options.json) {
163
+ process.stdout.write(`${JSON.stringify({ ...outcome, file: path.resolve(target) }, null, 2)}\n`);
164
+ }
165
+ else {
166
+ // The path on stdout and nothing else, so `$(dreamlayer generate ...)` is the file.
167
+ process.stdout.write(`${target}\n`);
168
+ }
169
+ return 0;
170
+ }
171
+ async function imageCommand(operation, prompt, file, options) {
172
+ const api = client();
173
+ const inputAssetId = await upload(api, file);
174
+ return run(api, { prompt, operation, input_asset_id: inputAssetId }, options);
175
+ }
176
+ /**
177
+ * Point a user at the job they may have paid for.
178
+ *
179
+ * Without this, a timed-out upscale left nothing to go on: no id, no command, and no
180
+ * key-authenticated way to check a balance. "It might have charged you, good luck" is
181
+ * not an acceptable end state for a paid call.
182
+ */
183
+ function recoveryHint(error) {
184
+ const id = error !== null && typeof error === "object"
185
+ ? error.partialOutcome
186
+ ?.execution_id
187
+ : null;
188
+ return id ? `The job may still be running. Check it with:\n dreamlayer status ${id}\n` : "";
189
+ }
190
+ function exitCodeFor(error) {
191
+ if (error.status === 401 || error.status === 403)
192
+ return 2;
193
+ if (error.status === 402)
194
+ return 3;
195
+ if (error.status === 409 || error.status === 422)
196
+ return 4;
197
+ return 5;
198
+ }
199
+ async function main(argv) {
200
+ const [command, ...rest] = argv;
201
+ if (!command || command === "--help" || command === "-h" || command === "help") {
202
+ process.stdout.write(USAGE);
203
+ return command ? 0 : 1;
204
+ }
205
+ if (command === "--version" || command === "-v") {
206
+ process.stdout.write("0.1.0\n");
207
+ return 0;
208
+ }
209
+ const { positional, options } = parseOptions(rest);
210
+ switch (command) {
211
+ case "generate": {
212
+ const prompt = positional[0];
213
+ if (!prompt)
214
+ throw new UsageError("generate needs a prompt");
215
+ return run(client(), { prompt, operation: "text_to_image", aspect_ratio: options.aspect }, options);
216
+ }
217
+ case "edit": {
218
+ const [file, prompt] = positional;
219
+ if (!file || !prompt)
220
+ throw new UsageError("edit needs an image and a prompt");
221
+ return imageCommand("image_to_image", prompt, file, options);
222
+ }
223
+ case "cutout": {
224
+ const file = positional[0];
225
+ if (!file)
226
+ throw new UsageError("cutout needs an image");
227
+ return imageCommand("background_remove", "remove the background", file, options);
228
+ }
229
+ case "upscale": {
230
+ const file = positional[0];
231
+ if (!file)
232
+ throw new UsageError("upscale needs an image");
233
+ return imageCommand("upscale", "upscale this image", file, options);
234
+ }
235
+ case "answer": {
236
+ const [conversationId, text] = positional;
237
+ if (!conversationId || !text)
238
+ throw new UsageError("answer needs a conversation id and text");
239
+ const api = client();
240
+ // A question that asks for an image cannot be answered with words alone: the
241
+ // server refuses a reply with no asset when the pending question required one.
242
+ // Without --image this command reached a clean 422 and the documented path
243
+ // dead-ended, telling the user to attach an image and offering no way to do it.
244
+ const input = options.image ? await upload(api, options.image) : undefined;
245
+ return run(api, {
246
+ respond: text,
247
+ conversation_id: conversationId,
248
+ ...(input ? { input_asset_id: input } : {}),
249
+ }, options);
250
+ }
251
+ case "status": {
252
+ const executionId = positional[0];
253
+ if (!executionId)
254
+ throw new UsageError("status needs an execution id");
255
+ const execution = await client().getExecution(executionId);
256
+ process.stdout.write(`${JSON.stringify(execution, null, 2)}\n`);
257
+ return 0;
258
+ }
259
+ case "capabilities": {
260
+ const capabilities = await client().getCapabilities();
261
+ process.stdout.write(`${JSON.stringify(capabilities, null, 2)}\n`);
262
+ return 0;
263
+ }
264
+ default:
265
+ throw new UsageError(`unknown command ${command}`);
266
+ }
267
+ }
268
+ main(process.argv.slice(2))
269
+ .then((code) => {
270
+ process.exitCode = code;
271
+ })
272
+ .catch((error) => {
273
+ if (error instanceof UsageError) {
274
+ process.stderr.write(`${error.message}\n`);
275
+ process.exitCode = 1;
276
+ return;
277
+ }
278
+ if (error instanceof ApiError) {
279
+ const hint = error.status === 402
280
+ ? "Buy credits at https://platform.dreamlayer.io/console/billing"
281
+ : error.retryable
282
+ ? "Temporary. Retry with --idempotency-key to avoid paying twice."
283
+ : "";
284
+ process.stderr.write(`${error.message}${hint ? `\n${hint}` : ""}\n`);
285
+ process.exitCode = exitCodeFor(error);
286
+ return;
287
+ }
288
+ // A stream that went silent is retryable, and it is the failure MOST likely to
289
+ // have been charged for: the server may have finished the job we stopped listening
290
+ // to. It reached this generic branch as a bare DOMException, so it exited 1 with no
291
+ // guidance, and the --idempotency-key advice that exists precisely to prevent
292
+ // double payment never printed on the one case that needs it.
293
+ if (error instanceof StreamIdleError) {
294
+ process.stderr.write(`${error.message}\n`);
295
+ process.stderr.write("Temporary. Retry with --idempotency-key to avoid paying twice.\n");
296
+ process.stderr.write(recoveryHint(error));
297
+ process.exitCode = 5;
298
+ return;
299
+ }
300
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
301
+ process.stderr.write(recoveryHint(error));
302
+ process.exitCode = 1;
303
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Hosted client for the DreamLayer Agent API.
3
+ *
4
+ * Deliberately a COPY of the same file in dreamlayer-mcp rather than a shared package.
5
+ * The alternative makes every capability three releases in strict order (client, then
6
+ * CLI, then MCP) instead of one release per repo. For a client over eight endpoints
7
+ * that trade is not worth the friction.
8
+ *
9
+ * Lifted from the DreamLayer runtime's TypeScript client, which is retired. The
10
+ * validation, error sanitisation, and origin hardening are kept verbatim because they
11
+ * were already correct; what changed is that the SSE reader is now wired to the hosted
12
+ * client instead of the dead local-proxy class, and that timeouts and an explicit
13
+ * redirect policy were added, which the original lacked.
14
+ */
15
+ export type ManagedEventName = "started" | "thinking" | "progress" | "job" | "question" | "asset" | "done";
16
+ export type ManagedEvent = {
17
+ id: string | null;
18
+ event: ManagedEventName;
19
+ data: Record<string, unknown>;
20
+ };
21
+ /**
22
+ * Every operation the Agent API can execute.
23
+ *
24
+ * REQUIRES the gateway build that added `operation` to ExecuteRequest. Against an older
25
+ * deployment this field is rejected with 422 extra_forbidden, because the request model
26
+ * is closed. That is a sequencing constraint, not a reason to drop it: naming the
27
+ * operation is what stops a cutout or an upscale being re-read from the prompt and
28
+ * coming back as a clarifying question instead of an image.
29
+ *
30
+ * SATISFIED 2026-08-21. prodbeta176 carries the field in the gateway AND the dispatch
31
+ * in the workflow engine, which had been split across two releases: the gateway
32
+ * accepted `operation` from prodbeta174 while the half that acts on it was still on
33
+ * prodbeta172, so naming an operation returned 200 and was then inferred from prose
34
+ * anyway. Confirmed against the deployment, not the source: the live /openapi.json
35
+ * advertises exactly these four in both ExecuteRequest and ImageJobCreate.
36
+ */
37
+ export type ManagedOperation = "text_to_image" | "image_to_image" | "background_remove" | "upscale";
38
+ export type ManagedExecuteInput = {
39
+ prompt?: string;
40
+ respond?: string;
41
+ conversation_id?: string;
42
+ input_asset_id?: string;
43
+ aspect_ratio?: string;
44
+ /** Requires the gateway build that added it. See ManagedOperation. */
45
+ operation?: ManagedOperation;
46
+ };
47
+ export type ManagedInputAsset = {
48
+ input_asset_id: string;
49
+ width: number;
50
+ height: number;
51
+ expires_at: string;
52
+ };
53
+ export type ManagedExecution = {
54
+ execution_id: string;
55
+ conversation_id: string;
56
+ status: string;
57
+ image_job: Record<string, unknown> | null;
58
+ };
59
+ export declare class ApiError extends Error {
60
+ readonly status: number;
61
+ readonly detail: string | null;
62
+ /** Server-assigned id for this failure. The only handle support can search on. */
63
+ readonly requestId: string | null;
64
+ constructor(status: number, surface?: string, detail?: string | null,
65
+ /** Server-assigned id for this failure. The only handle support can search on. */
66
+ requestId?: string | null);
67
+ /** Whether retrying with the same idempotency key is worth doing. */
68
+ get retryable(): boolean;
69
+ }
70
+ /**
71
+ * A stream that went silent, as distinct from a slow one.
72
+ *
73
+ * Thrown as a real error type because the CLI's exit codes and its retry advice are
74
+ * driven off the error, and a bare DOMException from AbortSignal fell through to the
75
+ * generic handler: exit 1 with no guidance, on the one failure most likely to have been
76
+ * charged for. See ManagedApiError.retryable.
77
+ */
78
+ export declare class StreamIdleError extends Error {
79
+ readonly idleMs: number;
80
+ constructor();
81
+ }
82
+ /**
83
+ * Validate one sanitized event against the published contract.
84
+ *
85
+ * Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
86
+ * schema is that a field appearing where none is documented means something changed
87
+ * server-side that a client should not silently consume.
88
+ */
89
+ export declare function managedEvent(event: string, id: string | null, value: unknown): ManagedEvent;
90
+ export declare class ManagedClient {
91
+ private readonly apiKey;
92
+ private readonly baseUrl;
93
+ constructor(apiKey: string, baseUrl?: string);
94
+ /**
95
+ * Run or continue an execution, yielding each validated event as it arrives.
96
+ *
97
+ * Streams rather than buffers. The Python server this replaces collected events into
98
+ * a list and threw the whole list away on overflow, taking the execution ID with it,
99
+ * so a caller could not even resume what it had already paid for.
100
+ */
101
+ execute(input: ManagedExecuteInput, options: {
102
+ idempotencyKey: string;
103
+ }): AsyncGenerator<ManagedEvent>;
104
+ /** Resume a stream after a drop. Pass the last event id you actually processed. */
105
+ events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
106
+ getCapabilities(): Promise<Record<string, unknown>>;
107
+ getExecution(executionId: string): Promise<ManagedExecution>;
108
+ cancel(executionId: string): Promise<ManagedExecution>;
109
+ listConversations(): Promise<Array<Record<string, unknown>>>;
110
+ deleteConversation(conversationId: string): Promise<void>;
111
+ uploadInput(file: Blob, filename?: string): Promise<ManagedInputAsset>;
112
+ /**
113
+ * Fetch a finished asset. Follows redirects on purpose: large images are served
114
+ * straight from storage rather than proxied, so a client that refuses redirects
115
+ * receives the redirect instead of the image.
116
+ */
117
+ download(url: string): Promise<Uint8Array>;
118
+ private parse;
119
+ private fetchStream;
120
+ private request;
121
+ }
package/dist/client.js ADDED
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Hosted client for the DreamLayer Agent API.
3
+ *
4
+ * Deliberately a COPY of the same file in dreamlayer-mcp rather than a shared package.
5
+ * The alternative makes every capability three releases in strict order (client, then
6
+ * CLI, then MCP) instead of one release per repo. For a client over eight endpoints
7
+ * that trade is not worth the friction.
8
+ *
9
+ * Lifted from the DreamLayer runtime's TypeScript client, which is retired. The
10
+ * validation, error sanitisation, and origin hardening are kept verbatim because they
11
+ * were already correct; what changed is that the SSE reader is now wired to the hosted
12
+ * client instead of the dead local-proxy class, and that timeouts and an explicit
13
+ * redirect policy were added, which the original lacked.
14
+ */
15
+ export class ApiError extends Error {
16
+ status;
17
+ detail;
18
+ requestId;
19
+ constructor(status, surface = "DreamLayer Agent API", detail = null,
20
+ /** Server-assigned id for this failure. The only handle support can search on. */
21
+ requestId = null) {
22
+ super(detail
23
+ ? `${detail}${requestId ? ` (request ${requestId})` : ""}`
24
+ : `${surface} request failed (${status})`);
25
+ this.status = status;
26
+ this.detail = detail;
27
+ this.requestId = requestId;
28
+ this.name = "ApiError";
29
+ }
30
+ /** Whether retrying with the same idempotency key is worth doing. */
31
+ get retryable() {
32
+ return this.status === 429 || this.status >= 500;
33
+ }
34
+ }
35
+ /**
36
+ * A stream that went silent, as distinct from a slow one.
37
+ *
38
+ * Thrown as a real error type because the CLI's exit codes and its retry advice are
39
+ * driven off the error, and a bare DOMException from AbortSignal fell through to the
40
+ * generic handler: exit 1 with no guidance, on the one failure most likely to have been
41
+ * charged for. See ManagedApiError.retryable.
42
+ */
43
+ export class StreamIdleError extends Error {
44
+ idleMs = STREAM_IDLE_TIMEOUT_MS;
45
+ constructor() {
46
+ super(`the stream sent nothing for ${Math.round(STREAM_IDLE_TIMEOUT_MS / 1000)}s, so the ` +
47
+ "connection is treated as dead. The job may still be running on the server.");
48
+ this.name = "StreamIdleError";
49
+ }
50
+ }
51
+ const ERROR_BODY_LIMIT = 16 * 1024;
52
+ const ERROR_DETAIL_LIMIT = 300;
53
+ /**
54
+ * A plain request: send, get a body back. Bounded work, so a total cap is right.
55
+ */
56
+ const REQUEST_TIMEOUT_MS = 130_000;
57
+ /**
58
+ * A STREAM is different, and conflating the two shipped a broken `upscale`.
59
+ *
60
+ * AbortSignal.timeout() caps TOTAL duration. An upscale of a 2048px image takes about
61
+ * 150s server-side, so a 130s total cap aborted every single one: a command that failed
62
+ * 100% of the time on a normal input, while the server had done the work and charged
63
+ * for it.
64
+ *
65
+ * Raising the number would fix upscale and break again on the next slower operation.
66
+ * The right question is not "how long may a job take" (unknowable, and it is the
67
+ * server's business) but "how long may we hear NOTHING before the connection is dead".
68
+ * The server sends `: keepalive` comments precisely so a client can tell those apart;
69
+ * a total-duration timeout throws that information away.
70
+ *
71
+ * So: idle timeout, reset on every byte received.
72
+ */
73
+ const STREAM_IDLE_DEFAULT_MS = 90_000;
74
+ /**
75
+ * Overridable, within bounds. Two honest reasons rather than one: a test cannot wait
76
+ * 90 seconds to prove a timeout fires, and a user on a genuinely bad link may need
77
+ * longer. Clamped so a typo cannot disable the guard entirely or set it to zero, and
78
+ * an unparseable value falls back rather than becoming NaN, which would abort instantly.
79
+ */
80
+ const STREAM_IDLE_TIMEOUT_MS = (() => {
81
+ const raw = Number(process.env.DREAMLAYER_STREAM_IDLE_MS);
82
+ if (!Number.isFinite(raw) || raw <= 0)
83
+ return STREAM_IDLE_DEFAULT_MS;
84
+ return Math.min(Math.max(raw, 100), 15 * 60_000);
85
+ })();
86
+ function isRecord(value) {
87
+ return typeof value === "object" && value !== null && !Array.isArray(value);
88
+ }
89
+ function sanitizedErrorDetail(value) {
90
+ if (typeof value !== "string")
91
+ return null;
92
+ const normalized = value
93
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
94
+ .replace(/\s+/g, " ")
95
+ .trim();
96
+ return normalized ? normalized.slice(0, ERROR_DETAIL_LIMIT) : null;
97
+ }
98
+ async function apiError(response, surface = "DreamLayer Agent API") {
99
+ let detail = null;
100
+ let requestId = null;
101
+ try {
102
+ const length = Number(response.headers.get("content-length") ?? "0");
103
+ if (!Number.isFinite(length) || length < 0 || length > ERROR_BODY_LIMIT) {
104
+ return new ApiError(response.status, surface);
105
+ }
106
+ const parsed = JSON.parse(await response.text());
107
+ if (isRecord(parsed)) {
108
+ // Two shapes in the wild. The gateway returns
109
+ // {"error":{"code","message","category","request_id"}}; older surfaces and
110
+ // FastAPI's own handlers return {"detail": "..."}. Reading only the latter is
111
+ // why every failure used to print a bare status code and nothing else.
112
+ detail = sanitizedErrorDetail(parsed.detail);
113
+ if (isRecord(parsed.error)) {
114
+ detail = detail ?? sanitizedErrorDetail(parsed.error.message);
115
+ requestId = sanitizedErrorDetail(parsed.error.request_id);
116
+ }
117
+ }
118
+ }
119
+ catch {
120
+ detail = null;
121
+ }
122
+ return new ApiError(response.status, surface, detail, requestId);
123
+ }
124
+ const MANAGED_EVENT_NAMES = new Set([
125
+ "started",
126
+ "thinking",
127
+ "progress",
128
+ "job",
129
+ "question",
130
+ "asset",
131
+ "done",
132
+ ]);
133
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
134
+ /**
135
+ * Validate one sanitized event against the published contract.
136
+ *
137
+ * Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
138
+ * schema is that a field appearing where none is documented means something changed
139
+ * server-side that a client should not silently consume.
140
+ */
141
+ export function managedEvent(event, id, value) {
142
+ if (!MANAGED_EVENT_NAMES.has(event) || !isRecord(value)) {
143
+ throw new Error("Invalid DreamLayer managed event");
144
+ }
145
+ const data = { ...value };
146
+ const exact = (required, optional = []) => {
147
+ const keys = Object.keys(data);
148
+ if (!required.every((key) => keys.includes(key)) ||
149
+ !keys.every((key) => required.includes(key) || optional.includes(key))) {
150
+ throw new Error("Invalid DreamLayer managed event fields");
151
+ }
152
+ };
153
+ const uuid = (key) => {
154
+ const candidate = data[key];
155
+ if (typeof candidate !== "string" || !UUID_PATTERN.test(candidate)) {
156
+ throw new Error("Invalid DreamLayer managed event identifier");
157
+ }
158
+ };
159
+ const text = (key, maximum) => {
160
+ const candidate = data[key];
161
+ if (typeof candidate !== "string" || candidate.length === 0 || candidate.length > maximum) {
162
+ throw new Error("Invalid DreamLayer managed event text");
163
+ }
164
+ };
165
+ switch (event) {
166
+ case "started":
167
+ exact(["execution_id", "conversation_id"]);
168
+ uuid("execution_id");
169
+ uuid("conversation_id");
170
+ break;
171
+ case "thinking":
172
+ exact([]);
173
+ break;
174
+ case "progress":
175
+ exact(["text"]);
176
+ text("text", 300);
177
+ break;
178
+ case "job":
179
+ exact(["public_job_id", "status"]);
180
+ uuid("public_job_id");
181
+ if (!["queued", "running", "completed", "failed"].includes(String(data.status))) {
182
+ throw new Error("Invalid DreamLayer managed job status");
183
+ }
184
+ break;
185
+ case "question":
186
+ exact(["question_id", "conversation_id", "text"]);
187
+ uuid("question_id");
188
+ uuid("conversation_id");
189
+ text("text", 300);
190
+ break;
191
+ case "asset":
192
+ exact(["asset_id", "download_url"]);
193
+ uuid("asset_id");
194
+ text("download_url", 500);
195
+ break;
196
+ case "done":
197
+ exact(["status"], ["conversation_id", "message"]);
198
+ if (!["needs_input", "completed", "failed", "cancelled"].includes(String(data.status))) {
199
+ throw new Error("Invalid DreamLayer managed completion status");
200
+ }
201
+ if (data.conversation_id !== undefined)
202
+ uuid("conversation_id");
203
+ if (data.message !== undefined)
204
+ text("message", 300);
205
+ break;
206
+ }
207
+ return { id, event: event, data };
208
+ }
209
+ /** Parse a server-sent-event body into blocks. Handles multi-line data and comments. */
210
+ async function* readEventStream(body, onBytes) {
211
+ const reader = body.getReader();
212
+ const decoder = new TextDecoder();
213
+ let buffer = "";
214
+ for (;;) {
215
+ const { value, done } = await reader.read();
216
+ // Any byte at all, including a `: keepalive` comment that parses to no event,
217
+ // proves the connection is alive. That is the signal the idle timer needs.
218
+ if (!done)
219
+ onBytes?.();
220
+ buffer += decoder.decode(value, { stream: !done });
221
+ let boundary = buffer.indexOf("\n\n");
222
+ while (boundary >= 0) {
223
+ const block = buffer.slice(0, boundary);
224
+ buffer = buffer.slice(boundary + 2);
225
+ const lines = block.split("\n");
226
+ const event = lines
227
+ .find((line) => line.startsWith("event:"))
228
+ ?.slice(6)
229
+ .trim();
230
+ const id = lines
231
+ .find((line) => line.startsWith("id:"))
232
+ ?.slice(3)
233
+ .trim() ?? null;
234
+ const data = lines
235
+ .filter((line) => line.startsWith("data:"))
236
+ .map((line) => line.slice(5).trim())
237
+ .join("\n");
238
+ if (event && data)
239
+ yield { event, id, data: JSON.parse(data) };
240
+ boundary = buffer.indexOf("\n\n");
241
+ }
242
+ if (done)
243
+ return;
244
+ }
245
+ }
246
+ /**
247
+ * Hosts this client will send a bearer key to.
248
+ *
249
+ * In August a build moved the endpoint default from api.dreamlayer.io to the bare
250
+ * marketing apex, and every request carried Authorization there for two days. The
251
+ * origin passed every cleanliness check below, because those check the SHAPE of a URL
252
+ * and never which host it names. An allowlist is the only thing that catches a host
253
+ * swap, which is why the gateway now has a pinned-origin test and why this mirrors it.
254
+ *
255
+ * DREAMLAYER_API_URL still works for a genuinely different deployment: set
256
+ * DREAMLAYER_ALLOW_ANY_HOST=1 alongside it and accept that you are vouching for the host.
257
+ */
258
+ const ALLOWED_HOSTS = new Set(["api.dreamlayer.io"]);
259
+ function isLoopback(hostname) {
260
+ return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]";
261
+ }
262
+ function managedOrigin(value) {
263
+ if (!value || value.endsWith("?") || value.endsWith("#")) {
264
+ throw new Error("Managed endpoint must be a clean HTTPS origin");
265
+ }
266
+ const parsed = new URL(value);
267
+ if (parsed.username ||
268
+ parsed.password ||
269
+ (parsed.pathname !== "" && parsed.pathname !== "/") ||
270
+ parsed.search ||
271
+ parsed.hash) {
272
+ throw new Error("Managed endpoint must be a clean HTTPS origin");
273
+ }
274
+ const loopback = isLoopback(parsed.hostname);
275
+ if (parsed.protocol !== "https:" && !(parsed.protocol === "http:" && loopback)) {
276
+ throw new Error("Managed endpoint must use HTTPS, except for exact loopback development");
277
+ }
278
+ const permitted = ALLOWED_HOSTS.has(parsed.hostname) ||
279
+ loopback ||
280
+ (process.env.DREAMLAYER_ALLOW_ANY_HOST ?? "").trim() === "1";
281
+ if (!permitted) {
282
+ throw new Error(`Refusing to send an API key to ${parsed.hostname}. ` +
283
+ `Expected api.dreamlayer.io. Set DREAMLAYER_ALLOW_ANY_HOST=1 to override.`);
284
+ }
285
+ return parsed.origin;
286
+ }
287
+ function requireEventStream(response) {
288
+ if (!response.headers.get("content-type")?.includes("text/event-stream")) {
289
+ throw new Error("DreamLayer managed endpoint did not return an event stream");
290
+ }
291
+ }
292
+ export class ManagedClient {
293
+ apiKey;
294
+ baseUrl;
295
+ constructor(apiKey, baseUrl = "https://api.dreamlayer.io") {
296
+ this.apiKey = apiKey;
297
+ if (!apiKey.trim())
298
+ throw new Error("DREAMLAYER_API_KEY is required");
299
+ this.baseUrl = managedOrigin(baseUrl);
300
+ }
301
+ /**
302
+ * Run or continue an execution, yielding each validated event as it arrives.
303
+ *
304
+ * Streams rather than buffers. The Python server this replaces collected events into
305
+ * a list and threw the whole list away on overflow, taking the execution ID with it,
306
+ * so a caller could not even resume what it had already paid for.
307
+ */
308
+ async *execute(input, options) {
309
+ const stream = await this.fetchStream("/v1/execute", {
310
+ method: "POST",
311
+ headers: {
312
+ Accept: "text/event-stream",
313
+ "Content-Type": "application/json",
314
+ "Idempotency-Key": options.idempotencyKey,
315
+ },
316
+ body: JSON.stringify(input),
317
+ });
318
+ yield* this.parse(stream);
319
+ }
320
+ /** Resume a stream after a drop. Pass the last event id you actually processed. */
321
+ async *events(executionId, lastEventId) {
322
+ const headers = { Accept: "text/event-stream" };
323
+ if (lastEventId)
324
+ headers["Last-Event-ID"] = lastEventId;
325
+ const stream = await this.fetchStream(`/v1/executions/${encodeURIComponent(executionId)}/events`, { headers });
326
+ yield* this.parse(stream);
327
+ }
328
+ async getCapabilities() {
329
+ const capabilities = await this.request("/v1/capabilities");
330
+ if (capabilities.api_version !== "1") {
331
+ throw new Error("Unsupported DreamLayer Agent API version");
332
+ }
333
+ return capabilities;
334
+ }
335
+ getExecution(executionId) {
336
+ return this.request(`/v1/executions/${encodeURIComponent(executionId)}`);
337
+ }
338
+ cancel(executionId) {
339
+ return this.request(`/v1/executions/${encodeURIComponent(executionId)}/cancel`, {
340
+ method: "POST",
341
+ });
342
+ }
343
+ listConversations() {
344
+ return this.request("/v1/conversations");
345
+ }
346
+ deleteConversation(conversationId) {
347
+ return this.request(`/v1/conversations/${encodeURIComponent(conversationId)}`, {
348
+ method: "DELETE",
349
+ });
350
+ }
351
+ uploadInput(file, filename = "input.png") {
352
+ const body = new FormData();
353
+ body.append("file", file, filename);
354
+ return this.request("/v1/input-assets", { method: "POST", body });
355
+ }
356
+ /**
357
+ * Fetch a finished asset. Follows redirects on purpose: large images are served
358
+ * straight from storage rather than proxied, so a client that refuses redirects
359
+ * receives the redirect instead of the image.
360
+ */
361
+ async download(url) {
362
+ // Only attach the key when the URL is OUR origin. download_url arrives in the event
363
+ // stream and is validated as text, so a wrong or hostile value would otherwise walk
364
+ // off with a live credential on the very first request. Node strips Authorization
365
+ // across a cross-origin redirect, so the hop to signed storage stays safe either way,
366
+ // and storage URLs are pre-signed and need no header from us.
367
+ const sameOrigin = (() => {
368
+ try {
369
+ return new URL(url).origin === this.baseUrl;
370
+ }
371
+ catch {
372
+ return false;
373
+ }
374
+ })();
375
+ const response = await fetch(url, {
376
+ headers: sameOrigin ? { Authorization: `Bearer ${this.apiKey}` } : {},
377
+ redirect: "follow",
378
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
379
+ });
380
+ if (!response.ok)
381
+ throw await apiError(response);
382
+ return new Uint8Array(await response.arrayBuffer());
383
+ }
384
+ async *parse(stream) {
385
+ const { response, keepAlive, finish } = stream;
386
+ if (!response.body) {
387
+ finish();
388
+ throw new Error("DreamLayer managed endpoint returned no body");
389
+ }
390
+ try {
391
+ for await (const block of readEventStream(response.body, keepAlive)) {
392
+ yield managedEvent(block.event, block.id, block.data);
393
+ }
394
+ }
395
+ finally {
396
+ // Also runs when the consumer breaks out of the loop early, which the CLI does
397
+ // as soon as it sees a terminal event. Without this the timer keeps the process
398
+ // alive for another idle period.
399
+ finish();
400
+ }
401
+ }
402
+ async fetchStream(path, init) {
403
+ const headers = new Headers(init.headers);
404
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
405
+ headers.set("DreamLayer-Version", "1");
406
+ // One controller for the whole stream, armed on an IDLE clock that every received
407
+ // byte pushes forward. The signal has to outlive the fetch() call: aborting only
408
+ // the handshake would leave a stalled body hanging forever.
409
+ const controller = new AbortController();
410
+ let timer;
411
+ const keepAlive = () => {
412
+ if (timer)
413
+ clearTimeout(timer);
414
+ timer = setTimeout(() => controller.abort(new StreamIdleError()), STREAM_IDLE_TIMEOUT_MS);
415
+ timer.unref?.();
416
+ };
417
+ const finish = () => {
418
+ if (timer)
419
+ clearTimeout(timer);
420
+ timer = undefined;
421
+ };
422
+ keepAlive();
423
+ try {
424
+ const response = await fetch(`${this.baseUrl}${path}`, {
425
+ ...init,
426
+ headers,
427
+ redirect: "manual",
428
+ signal: controller.signal,
429
+ });
430
+ if (!response.ok) {
431
+ finish();
432
+ throw await apiError(response);
433
+ }
434
+ requireEventStream(response);
435
+ return { response, keepAlive, finish };
436
+ }
437
+ catch (error) {
438
+ finish();
439
+ throw error;
440
+ }
441
+ }
442
+ async request(path, init = {}) {
443
+ const headers = new Headers(init.headers);
444
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
445
+ headers.set("DreamLayer-Version", "1");
446
+ const response = await fetch(`${this.baseUrl}${path}`, {
447
+ ...init,
448
+ headers,
449
+ redirect: "manual",
450
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
451
+ });
452
+ if (!response.ok)
453
+ throw await apiError(response);
454
+ if (response.status === 204)
455
+ return undefined;
456
+ return (await response.json());
457
+ }
458
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Terminal rendering of an execution.
3
+ *
4
+ * Two modes, decided by whether stdout is a TTY and whether --json was passed. A CLI
5
+ * that only prints prose cannot be piped into anything, and one that only prints JSON
6
+ * is miserable to watch. Progress goes to stderr so `dreamlayer generate ... | jq`
7
+ * works without the spinner corrupting the pipe.
8
+ */
9
+ import type { ManagedEvent } from "./client.js";
10
+ export type Outcome = {
11
+ execution_id: string | null;
12
+ conversation_id: string | null;
13
+ status: string;
14
+ asset: {
15
+ asset_id: string;
16
+ download_url: string;
17
+ } | null;
18
+ question: {
19
+ question_id: string;
20
+ text: string;
21
+ } | null;
22
+ last_event_id: string | null;
23
+ };
24
+ export declare class Progress {
25
+ private readonly enabled;
26
+ private timer;
27
+ private frame;
28
+ private label;
29
+ constructor(enabled: boolean);
30
+ set(label: string): void;
31
+ stop(final?: string): void;
32
+ }
33
+ export declare function consume(stream: AsyncGenerator<ManagedEvent>, progress: Progress, onEvent?: (event: ManagedEvent) => void): Promise<Outcome>;
package/dist/render.js ADDED
@@ -0,0 +1,113 @@
1
+ const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2
+ export class Progress {
3
+ enabled;
4
+ timer = null;
5
+ frame = 0;
6
+ label = "";
7
+ constructor(enabled) {
8
+ this.enabled = enabled;
9
+ }
10
+ set(label) {
11
+ this.label = label;
12
+ if (!this.enabled)
13
+ return;
14
+ if (this.timer === null) {
15
+ this.timer = setInterval(() => {
16
+ this.frame = (this.frame + 1) % FRAMES.length;
17
+ process.stderr.write(`\r${FRAMES[this.frame]} ${this.label}`);
18
+ }, 90);
19
+ this.timer.unref();
20
+ }
21
+ }
22
+ stop(final) {
23
+ if (this.timer !== null) {
24
+ clearInterval(this.timer);
25
+ this.timer = null;
26
+ }
27
+ if (!this.enabled) {
28
+ if (final)
29
+ process.stderr.write(`${final}\n`);
30
+ return;
31
+ }
32
+ process.stderr.write(`\r`);
33
+ if (final)
34
+ process.stderr.write(`${final}\n`);
35
+ }
36
+ }
37
+ /** Human wording for each event. `thinking` carries no text by contract. */
38
+ function describe(event) {
39
+ switch (event.event) {
40
+ case "started":
41
+ return "Starting";
42
+ case "thinking":
43
+ return "Working";
44
+ case "progress":
45
+ return String(event.data.text ?? "Working");
46
+ case "job":
47
+ return `Job ${String(event.data.status)}`;
48
+ case "question":
49
+ return null;
50
+ case "asset":
51
+ return "Downloading";
52
+ case "done":
53
+ return null;
54
+ }
55
+ }
56
+ export async function consume(stream, progress, onEvent) {
57
+ const outcome = {
58
+ execution_id: null,
59
+ conversation_id: null,
60
+ status: "unknown",
61
+ asset: null,
62
+ question: null,
63
+ last_event_id: null,
64
+ };
65
+ try {
66
+ for await (const event of stream) {
67
+ onEvent?.(event);
68
+ outcome.last_event_id = event.id;
69
+ const label = describe(event);
70
+ if (label)
71
+ progress.set(label);
72
+ if (event.event === "started") {
73
+ outcome.execution_id = String(event.data.execution_id);
74
+ outcome.conversation_id = String(event.data.conversation_id);
75
+ }
76
+ else if (event.event === "asset") {
77
+ outcome.asset = {
78
+ asset_id: String(event.data.asset_id),
79
+ download_url: String(event.data.download_url),
80
+ };
81
+ }
82
+ else if (event.event === "question") {
83
+ outcome.question = {
84
+ question_id: String(event.data.question_id),
85
+ text: String(event.data.text),
86
+ };
87
+ if (event.data.conversation_id) {
88
+ outcome.conversation_id = String(event.data.conversation_id);
89
+ }
90
+ }
91
+ else if (event.event === "done") {
92
+ outcome.status = String(event.data.status);
93
+ if (event.data.conversation_id) {
94
+ outcome.conversation_id = String(event.data.conversation_id);
95
+ }
96
+ }
97
+ }
98
+ }
99
+ catch (error) {
100
+ // `started` arrives within seconds and carries the execution id. When the stream
101
+ // later dies, that id is the only way a user can find a job they may already have
102
+ // paid for, and it was being discarded along with the exception.
103
+ //
104
+ // Attached to the error rather than wrapped in a new one: the top-level handler
105
+ // branches on `instanceof ApiError`, and a wrapper would silently defeat that
106
+ // while looking tidier.
107
+ if (error !== null && typeof error === "object") {
108
+ error.partialOutcome = outcome;
109
+ }
110
+ throw error;
111
+ }
112
+ return outcome;
113
+ }
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "dreamlayer",
3
+ "version": "0.1.0",
4
+ "description": "Generate and edit images from your terminal, over local files, with one API key.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "dreamlayer": "./dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE",
14
+ "NOTICE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=22.12"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
21
+ "test": "pnpm build && node --test test/*.test.mjs",
22
+ "pack:check": "npm pack --dry-run",
23
+ "prepublishOnly": "pnpm test"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "5.9.2",
27
+ "@types/node": "^22.10.0"
28
+ },
29
+ "keywords": [
30
+ "image-generation",
31
+ "cli",
32
+ "dreamlayer",
33
+ "ai",
34
+ "image-editing"
35
+ ],
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/TheDesignFounder/dreamlayer-cli.git"
39
+ },
40
+ "homepage": "https://docs.dreamlayer.io/cli"
41
+ }