Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dreamlayer cutout <image> [--out file.png] # background removal
dreamlayer upscale <image> [--out file.png] # 2x
dreamlayer answer <conversation-id> <text> [--image file.png]
dreamlayer status <execution-id>
dreamlayer download <execution-id> --out file.png
dreamlayer balance # spends nothing
dreamlayer capabilities # spends nothing
```
Expand Down Expand Up @@ -77,8 +78,8 @@ dreamlayer balance --json

## Retries are safe if you reuse the key

An idempotency key is generated per run. After an uncertain response, pass the same one
back and the original result replays instead of paying twice:
Save a key before starting text generation. After an uncertain response, reuse that key
and the identical prompt/options, or check the existing execution first:

```bash
dreamlayer generate "a fox logo" --idempotency-key fox-001
Expand Down Expand Up @@ -124,3 +125,27 @@ dreamlayer status EXECUTION_ID
Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes.

For affordability, compare the complete rounded quote in **credits** with `available`. One tenth of a credit is $0.017. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.1 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier.

## Automating recovery

Commands never prompt. `--json` sends results to stdout and structured errors to stderr,
including usage and transport errors. Exit 0 from `status` means the read succeeded;
inspect its `status` field to learn whether the execution completed.

After a lost download, recover the existing execution without generation:

```sh
dreamlayer status EXECUTION_ID --json
dreamlayer download EXECUTION_ID --out recovered.png --json
```

`download` refuses to overwrite a file. Use `.zip` for sprite results. Generation errors
in JSON include the available execution ID and idempotency key for recovery. Treat them
as private identifiers. For file-based commands, rerunning uploads a new asset: the same
local file is not an identical API request. Prefer `status` and `download`, or the
[journaled API examples](https://docs.dreamlayer.io/agent-api/examples).

[API overview](https://docs.dreamlayer.io/agent-api) ·
[Limits](https://docs.dreamlayer.io/agent-api/limits) ·
[Automation](https://docs.dreamlayer.io/cli/automation) ·
[MCP tools](https://docs.dreamlayer.io/mcp/tools)
10 changes: 10 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Agent readiness release

Prepared version: `0.4.0-beta.2`. Keep `latest` on `0.3.0` until a stable release is deliberately selected.

1. Pass CI, review and merge the change.
2. With npm publisher authentication, run `pnpm test` and `npm publish --tag beta`.
3. Verify `npm view dreamlayer dist-tags`, install the package in a clean directory, and run `dreamlayer --version` and `dreamlayer download --help` without credentials.
4. Update pinned documentation examples only after the version exists on npm.

No paid generation is necessary to verify installation or help output.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dreamlayer",
"version": "0.4.0-beta.1",
"version": "0.4.0-beta.2",
"description": "Generate and edit images from your terminal, over local files, with one API key.",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -31,7 +31,9 @@
"cli",
"dreamlayer",
"ai",
"image-editing"
"image-editing",
"sprite-sheet",
"automation"
],
"repository": {
"type": "git",
Expand Down
54 changes: 49 additions & 5 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ USAGE
dreamlayer upscale <image> [--out <file>]
dreamlayer sprite <image> --action <walk|run|idle> [--frames <7–100>] --max-credits <n> [--out <zip>]
dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
dreamlayer download <execution-id> --out <file>
dreamlayer status <execution-id>
dreamlayer balance
dreamlayer capabilities
Expand All @@ -60,10 +61,19 @@ OPTIONS
--frame-size <px> Square export: 32, 64, 128, 256, 512 (default), 720, 1080
--frames <n> Frame count: integer 7–100, default 12
--max-credits <n> Maximum approved charge for the sprite job
--json Machine-readable output on stdout
--json JSON results on stdout; JSON errors on stderr
--quiet No progress on stderr
--idempotency-key <key> Reuse to retry safely after an uncertain response

EXIT CODES
0 success, 1 usage/local error, 2 authentication/access, 3 credits/quota,
4 permanent API failure, 5 temporary failure, 6 input required

AUTOMATION
Commands never prompt. Save a unique --idempotency-key before paid work.
After uncertainty, use status then download; do not start a replacement job.
JSON output is documented at https://docs.dreamlayer.io/cli/automation

ENVIRONMENT
DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
Expand Down Expand Up @@ -189,19 +199,23 @@ function defaultOut(): string {
return `dreamlayer-${Date.now()}.png`;
}

let recovery: { idempotency_key?: string; execution_id?: string | null } = {};

async function run(
api: ManagedClient,
input: ManagedExecuteInput,
options: Options,
): Promise<number> {
const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
const idempotencyKey = options.idempotencyKey ?? randomUUID();
recovery = { idempotency_key: idempotencyKey };
const outcome = await consume(api.follow(input, { idempotencyKey }), progress);
recovery.execution_id = outcome.execution_id;

if (outcome.question) {
progress.stop();
if (options.json) {
process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`);
} else {
process.stderr.write(`\nDreamLayer needs one more thing:\n ${outcome.question.text}\n\n`);
// The server's needs_input question always asks for an image, so point at the
Expand All @@ -221,8 +235,8 @@ async function run(
const terminal = terminalExecutionError(await api.getExecution(outcome.execution_id));
if (terminal) throw terminal;
}
if (options.json) process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
else process.stderr.write(`Run ended as ${outcome.status}. No credit was settled.\n`);
if (options.json) process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`);
else process.stderr.write(`Run ended as ${outcome.status}. Check the execution before retrying.\n`);
return 5;
}

Expand All @@ -233,7 +247,7 @@ async function run(
progress.stop();

if (options.json) {
process.stdout.write(`${JSON.stringify({ ...outcome, file: path.resolve(target) }, null, 2)}\n`);
process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey, file: path.resolve(target) }, null, 2)}\n`);
} else {
// The path on stdout and nothing else, so `$(dreamlayer generate ...)` is the file.
process.stdout.write(`${target}\n`);
Expand Down Expand Up @@ -325,6 +339,10 @@ async function main(argv: string[]): Promise<number> {
process.stdout.write(USAGE);
return command ? 0 : 1;
}
if (rest.includes("--help") || rest.includes("-h")) {
process.stdout.write(USAGE);
return 0;
}
if (command === "--version" || command === "-v") {
process.stdout.write(`${PACKAGE_VERSION}\n`);
return 0;
Expand Down Expand Up @@ -388,6 +406,19 @@ async function main(argv: string[]): Promise<number> {
options,
);
}
case "download": {
const executionId = positional[0];
if (!executionId || positional.length !== 1 || !options.out) throw new UsageError("download needs one execution id and --out <file>");
const api = client();
recovery = { execution_id: executionId };
const execution = await api.getExecution(executionId);
const assets = execution.image_job?.finished_assets;
if (execution.status !== "completed" || !Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string") throw new UsageError("The execution has no finished asset. Check status before downloading.");
const bytes = await api.download(assets[0].download_url);
await writeFile(options.out, bytes, { flag: "wx" });
process.stdout.write(options.json ? `${JSON.stringify({ execution_id: executionId, file: path.resolve(options.out), bytes: bytes.length })}\n` : `${options.out}\n`);
return 0;
}
case "status": {
const executionId = positional[0];
if (!executionId) throw new UsageError("status needs an execution id");
Expand Down Expand Up @@ -427,6 +458,19 @@ main(process.argv.slice(2))
process.exitCode = code;
})
.catch((error: unknown) => {
if (process.argv.slice(2).includes("--json")) {
const partial = (error as { partialOutcome?: { execution_id?: string | null } } | null)?.partialOutcome;
const temporary = error instanceof StreamIdleError || error instanceof UploadTimeoutError;
const envelope = error instanceof ApiError ? error.toPublicEnvelope() : {
error: { code: error instanceof UsageError ? "VALIDATION_FAILED" : temporary ? "SERVICE_UNAVAILABLE" : "INTERNAL_ERROR",
reason: error instanceof UsageError ? "invalid_request" : temporary ? "temporarily_unavailable" : "generation_failed",
message: error instanceof UsageError ? "Check command arguments and local input or output files; use --help." : temporary ? "The request timed out. Check the saved execution before retrying." : "The command could not complete. Check saved execution state and local output access.",
retryable: temporary, request_id: null },
};
process.stderr.write(`${JSON.stringify({ error: { ...(envelope.error as Record<string, unknown>), ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) } })}\n`);
process.exitCode = error instanceof ApiError ? exitCodeFor(error) : temporary ? 5 : 1;
return;
}
if (error instanceof UsageError) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
Expand Down
41 changes: 40 additions & 1 deletion test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ test("balance rejects inconsistent or expanded responses without echoing private
api.close();

assert.equal(result.code, 1);
assert.match(result.stderr, /Invalid DreamLayer balance response/);
assert.equal(JSON.parse(result.stderr).error.code, "INTERNAL_ERROR");
assert.doesNotMatch(result.stderr, /do-not-print-this/);
assert.equal(result.stdout, "");
});
Expand Down Expand Up @@ -981,3 +981,42 @@ test("fractional funding explains affordability without changing JSON balances",
assert.deepEqual(JSON.parse(json.stdout), body);
} finally { api.close(); }
});


test("JSON usage errors remain parseable and do not leak local arguments", async () => {
const result = await runCli(["edit", "/private/customer-photo.png", "--json"], {});
assert.equal(result.code, 1);
assert.equal(result.stdout, "");
assert.equal(JSON.parse(result.stderr).error.reason, "invalid_request");
assert.doesNotMatch(result.stderr, /customer-photo/);
});

test("subcommand help requires no credentials or network", async () => {
const result = await runCli(["sprite", "--help"], { DREAMLAYER_API_KEY: "" });
assert.equal(result.code, 0);
assert.match(result.stdout, /EXIT CODES/);
});

test("download recovers existing assets using GET only and refuses overwrite", async () => {
const handler = fakeApi({});
const api = await listen(handler);
const directory = await mkdtemp(path.join(tmpdir(), "dreamlayer-download-"));
const destination = path.join(directory, "result.png");
handler.server.removeAllListeners('request');
handler.server.on('request', (request, response) => {
handler.calls.push({ method: request.method, url: request.url });
if (request.url === '/asset.png') return response.end(PNG);
response.setHeader('content-type', 'application/json');
response.end(JSON.stringify({ status: 'completed', image_job: { finished_assets: [{ download_url: api.url + '/asset.png' }] } }));
});
try {
const result = await runCli(['download', 'owned', '--out', destination, '--json'], { DREAMLAYER_API_URL: api.url });
assert.equal(result.code, 0, result.stderr);
assert.equal(JSON.parse(result.stdout).execution_id, 'owned');
assert.deepEqual(await readFile(destination), PNG);
assert.ok(handler.calls.every(call => call.method === 'GET'));
const duplicate = await runCli(['download', 'owned', '--out', destination, '--json'], { DREAMLAYER_API_URL: api.url });
assert.equal(duplicate.code, 1);
assert.deepEqual(await readFile(destination), PNG);
} finally { api.close(); }
});
4 changes: 2 additions & 2 deletions test/packaged-install.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout:
assert.equal(packed.status, 0, packed.stderr);
const metadata = JSON.parse(packed.stdout)[0];
assert.equal(metadata.name, "dreamlayer");
assert.equal(metadata.version, "0.4.0-beta.1");
assert.equal(metadata.version, "0.4.0-beta.2");
assert.ok(metadata.integrity.startsWith("sha512-"));
assert.deepEqual(
metadata.files.map(({ path: file }) => file).sort(),
Expand Down Expand Up @@ -57,7 +57,7 @@ test("the packed CLI installs offline and its shipped binary starts", { timeout:
const packageJson = JSON.parse(
await readFile(path.join(installRoot, "node_modules", "dreamlayer", "package.json"), "utf8"),
);
assert.equal(packageJson.version, "0.4.0-beta.1");
assert.equal(packageJson.version, "0.4.0-beta.2");
assert.equal(packageJson.bin.dreamlayer, "./dist/cli.js");

const env = { ...process.env, DREAMLAYER_API_KEY: "" };
Expand Down
Loading