@dench.com/cli 0.3.5 → 0.3.7

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/image.ts ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * `dench image generate|edit` — gpt-image-2 backed image creation through
3
+ * the Dench Cloud Gateway.
4
+ *
5
+ * Mirrors `dench search`: bypasses Convex entirely and talks straight HTTP
6
+ * to the gateway, authenticating with the `DENCH_API_KEY` baked into every
7
+ * sandbox at create time. Works anywhere that env var is exported (sandbox,
8
+ * CI, local dev).
9
+ *
10
+ * Persistence model: the gateway already writes the bytes to Convex Storage
11
+ * + the org's fileTree on success (so the workspace UI sees the file
12
+ * immediately without any client-side orchestration). When the CLI is
13
+ * running inside a sandbox, we ALSO write the decoded bytes to disk at
14
+ * `--output` (default: `/workspace/image-generations/...`) so the very
15
+ * next bash command (e.g. `convert image.png -resize 50% small.png`) can
16
+ * see it without waiting on the daemon's downward sync — the same
17
+ * defense-in-depth pattern the chat-turn workflow tool uses.
18
+ *
19
+ * Subcommands:
20
+ * image generate "<prompt>" [--size 1024x1024] [--quality auto|low|medium|high]
21
+ * [--format png|jpeg|webp] [--save-path /workspace/...]
22
+ * [--output <local file>] [--no-write] [--json]
23
+ * image edit "<prompt>" --input <path> [--input <path> ...] [--mask <path>]
24
+ * [--size ...] [--quality ...] [--format ...] [--save-path ...]
25
+ * [--output ...] [--no-write] [--json]
26
+ */
27
+
28
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
29
+ import { dirname, isAbsolute, resolve } from "node:path";
30
+ import {
31
+ CliArgError,
32
+ getFlag,
33
+ hasFlag,
34
+ shift as shiftRaw,
35
+ } from "./lib/cli-args";
36
+
37
+ type ImageCliContext = {
38
+ args: string[];
39
+ jsonOutput: boolean;
40
+ };
41
+
42
+ class ImageCliError extends Error {}
43
+
44
+ function shift(args: string[], expected: string): string {
45
+ try {
46
+ return shiftRaw(args, expected);
47
+ } catch (error) {
48
+ if (error instanceof CliArgError) {
49
+ throw new ImageCliError(error.message);
50
+ }
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ const ALLOWED_QUALITIES = new Set(["auto", "low", "medium", "high"]);
56
+ const ALLOWED_FORMATS = new Set(["png", "jpeg", "webp"]);
57
+
58
+ function resolveGatewayBaseUrl(): string {
59
+ const explicit =
60
+ process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
61
+ if (explicit) return explicit.replace(/\/+$/, "");
62
+ const host = process.env.DENCH_HOST?.trim();
63
+ if (host && /localhost|127\.0\.0\.1/.test(host)) {
64
+ return "http://localhost:8787";
65
+ }
66
+ return "https://gateway.merseoriginals.com";
67
+ }
68
+
69
+ function requireApiKey(): string {
70
+ const apiKey = process.env.DENCH_API_KEY?.trim();
71
+ if (!apiKey) {
72
+ throw new ImageCliError(
73
+ "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " +
74
+ "the env automatically; outside, export it manually or run " +
75
+ "`dench login` first.",
76
+ );
77
+ }
78
+ return apiKey;
79
+ }
80
+
81
+ function resolveDefaultVolumeRoot(): string {
82
+ // Inside a sandbox the volume is mounted at `/workspace` and the
83
+ // gateway emits paths like `/image-generations/...png`. Outside a
84
+ // sandbox, write to `./image-generations/...png` so things still
85
+ // work in dev / CI.
86
+ return process.env.DENCH_VOLUME_PATH?.trim() || "/workspace";
87
+ }
88
+
89
+ function resolveOutputPath(
90
+ explicit: string | undefined,
91
+ gatewayPath: string,
92
+ ): string {
93
+ const root = resolveDefaultVolumeRoot();
94
+ if (explicit && explicit.length > 0) {
95
+ return isAbsolute(explicit) ? explicit : resolve(process.cwd(), explicit);
96
+ }
97
+ // gatewayPath is `/image-generations/<file>.png` (POSIX, leading slash).
98
+ return resolve(root, gatewayPath.replace(/^\/+/, ""));
99
+ }
100
+
101
+ async function readBase64FromPath(path: string): Promise<string> {
102
+ const absolute = isAbsolute(path) ? path : resolve(process.cwd(), path);
103
+ const bytes = await readFile(absolute);
104
+ return bytes.toString("base64");
105
+ }
106
+
107
+ type GatewayImageResponse = {
108
+ ok: boolean;
109
+ model?: string;
110
+ endpoint?: string;
111
+ prompt?: string;
112
+ revisedPrompt?: string;
113
+ quality?: string;
114
+ requestedSize?: string;
115
+ format?: string;
116
+ mimeType?: string;
117
+ estimatedCostUsd?: number;
118
+ b64_json?: string;
119
+ storageId?: string;
120
+ path?: string;
121
+ signedUrl?: string | null;
122
+ contentHash?: string;
123
+ sizeBytes?: number;
124
+ usage?: Record<string, number | undefined>;
125
+ };
126
+
127
+ async function callGateway(
128
+ path: "/v1/images/generations" | "/v1/images/edits",
129
+ body: Record<string, unknown>,
130
+ ): Promise<GatewayImageResponse> {
131
+ const apiKey = requireApiKey();
132
+ const baseUrl = resolveGatewayBaseUrl();
133
+ const response = await fetch(`${baseUrl}${path}`, {
134
+ method: "POST",
135
+ headers: {
136
+ "content-type": "application/json",
137
+ Authorization: `Bearer ${apiKey}`,
138
+ },
139
+ body: JSON.stringify(body),
140
+ });
141
+ if (!response.ok) {
142
+ let detail = "";
143
+ try {
144
+ detail = await response.text();
145
+ } catch {
146
+ // ignore
147
+ }
148
+ const truncated = detail.length > 500 ? `${detail.slice(0, 500)}…` : detail;
149
+ throw new ImageCliError(
150
+ `Gateway ${path} returned ${response.status}: ${truncated || response.statusText}`,
151
+ );
152
+ }
153
+ return (await response.json()) as GatewayImageResponse;
154
+ }
155
+
156
+ async function writeBytesToDisk(
157
+ outputPath: string,
158
+ base64: string,
159
+ ): Promise<{ path: string; size: number }> {
160
+ await mkdir(dirname(outputPath), { recursive: true });
161
+ const bytes = Buffer.from(base64, "base64");
162
+ await writeFile(outputPath, bytes);
163
+ return { path: outputPath, size: bytes.byteLength };
164
+ }
165
+
166
+ function out(
167
+ ctx: ImageCliContext,
168
+ payload: Record<string, unknown>,
169
+ formatted: string,
170
+ ): void {
171
+ if (ctx.jsonOutput) {
172
+ console.log(JSON.stringify(payload, null, 2));
173
+ return;
174
+ }
175
+ console.log(formatted);
176
+ }
177
+
178
+ function formatGenerationResult(
179
+ res: GatewayImageResponse,
180
+ localPath: string | null,
181
+ ): string {
182
+ const lines: string[] = [];
183
+ lines.push(`Generated image (${res.model ?? "gpt-image-2"})`);
184
+ if (res.prompt) {
185
+ lines.push(` Prompt: ${res.prompt}`);
186
+ }
187
+ if (res.revisedPrompt && res.revisedPrompt !== res.prompt) {
188
+ lines.push(` Revised: ${res.revisedPrompt}`);
189
+ }
190
+ if (res.requestedSize) lines.push(` Size: ${res.requestedSize}`);
191
+ if (res.quality) lines.push(` Quality: ${res.quality}`);
192
+ if (res.format) lines.push(` Format: ${res.format}`);
193
+ if (typeof res.estimatedCostUsd === "number") {
194
+ lines.push(` Cost (USD): $${res.estimatedCostUsd.toFixed(4)}`);
195
+ }
196
+ if (res.path) lines.push(` Workspace: ${res.path}`);
197
+ if (res.signedUrl) lines.push(` Signed URL: ${res.signedUrl}`);
198
+ if (localPath) lines.push(` Local file: ${localPath}`);
199
+ return lines.join("\n");
200
+ }
201
+
202
+ // ────────────────────────────────────────────────────────────────────────────
203
+ // Subcommands
204
+ // ────────────────────────────────────────────────────────────────────────────
205
+
206
+ type CommonImageOpts = {
207
+ size?: string;
208
+ quality?: string;
209
+ format?: string;
210
+ savePath?: string;
211
+ output?: string;
212
+ noWrite: boolean;
213
+ background?: string;
214
+ moderation?: string;
215
+ };
216
+
217
+ function parseCommonOpts(args: string[]): CommonImageOpts {
218
+ const size = getFlag(args, "--size");
219
+ const quality = getFlag(args, "--quality");
220
+ if (quality !== undefined && !ALLOWED_QUALITIES.has(quality)) {
221
+ throw new ImageCliError(
222
+ `Invalid --quality: ${quality}. Allowed: ${[...ALLOWED_QUALITIES].join(", ")}`,
223
+ );
224
+ }
225
+ const format = getFlag(args, "--format");
226
+ if (format !== undefined && !ALLOWED_FORMATS.has(format)) {
227
+ throw new ImageCliError(
228
+ `Invalid --format: ${format}. Allowed: ${[...ALLOWED_FORMATS].join(", ")}`,
229
+ );
230
+ }
231
+ const savePath = getFlag(args, "--save-path");
232
+ const output = getFlag(args, "--output") ?? getFlag(args, "-o");
233
+ const noWrite = hasFlag(args, "--no-write");
234
+ const background = getFlag(args, "--background");
235
+ if (
236
+ background !== undefined &&
237
+ !["auto", "opaque", "transparent"].includes(background)
238
+ ) {
239
+ throw new ImageCliError(
240
+ `Invalid --background: ${background}. Allowed: auto, opaque, transparent`,
241
+ );
242
+ }
243
+ const moderation = getFlag(args, "--moderation");
244
+ if (moderation !== undefined && !["auto", "low"].includes(moderation)) {
245
+ throw new ImageCliError(
246
+ `Invalid --moderation: ${moderation}. Allowed: auto, low`,
247
+ );
248
+ }
249
+ return { size, quality, format, savePath, output, noWrite, background, moderation };
250
+ }
251
+
252
+ function buildGenerationBody(
253
+ prompt: string,
254
+ opts: CommonImageOpts,
255
+ ): Record<string, unknown> {
256
+ const body: Record<string, unknown> = { prompt };
257
+ if (opts.size) body.size = opts.size;
258
+ if (opts.quality) body.quality = opts.quality;
259
+ if (opts.format) body.format = opts.format;
260
+ if (opts.savePath) body.savePath = opts.savePath;
261
+ if (opts.background) body.background = opts.background;
262
+ if (opts.moderation) body.moderation = opts.moderation;
263
+ // Allow callers to pin organizationId when running with a wildcard
264
+ // key (rare; org-bound keys take precedence on the gateway side).
265
+ const orgId = process.env.DENCH_ORG_ID?.trim();
266
+ if (orgId) body.organizationId = orgId;
267
+ const runId = process.env.DENCH_RUN_ID?.trim();
268
+ if (runId) body.runId = runId;
269
+ return body;
270
+ }
271
+
272
+ async function runGenerateSubcommand(ctx: ImageCliContext): Promise<void> {
273
+ const opts = parseCommonOpts(ctx.args);
274
+ // Consume optional --print-b64 BEFORE we collapse the remainder into
275
+ // the prompt, otherwise it would be glued onto the prompt string.
276
+ const printB64 = hasFlag(ctx.args, "--print-b64");
277
+ const promptParts = ctx.args.slice();
278
+ ctx.args.length = 0;
279
+ const prompt = promptParts.join(" ").trim();
280
+ if (!prompt) {
281
+ throw new ImageCliError(
282
+ 'Usage: dench image generate "<prompt>" [--size 1024x1024] [--quality auto|low|medium|high] [--format png|jpeg|webp] [--save-path /workspace/...] [--output <local>] [--no-write] [--json]',
283
+ );
284
+ }
285
+ const body = buildGenerationBody(prompt, opts);
286
+ const result = await callGateway("/v1/images/generations", body);
287
+ if (!result.b64_json) {
288
+ throw new ImageCliError("Gateway response did not include image bytes.");
289
+ }
290
+
291
+ let localPath: string | null = null;
292
+ if (!opts.noWrite) {
293
+ const target = resolveOutputPath(
294
+ opts.output,
295
+ result.path ?? `/image-generations/output.${result.format ?? "png"}`,
296
+ );
297
+ const written = await writeBytesToDisk(target, result.b64_json);
298
+ localPath = written.path;
299
+ }
300
+
301
+ out(
302
+ ctx,
303
+ {
304
+ ...result,
305
+ ok: true,
306
+ localPath,
307
+ // Strip the bytes from JSON output by default — they're already
308
+ // on disk + Convex. Re-include with --print-b64.
309
+ b64_json: ctx.jsonOutput && printB64 ? result.b64_json : undefined,
310
+ },
311
+ formatGenerationResult(result, localPath),
312
+ );
313
+ }
314
+
315
+ async function runEditSubcommand(ctx: ImageCliContext): Promise<void> {
316
+ const opts = parseCommonOpts(ctx.args);
317
+ const printB64 = hasFlag(ctx.args, "--print-b64");
318
+ const inputs: string[] = [];
319
+ // Repeatable --input flag.
320
+ while (true) {
321
+ const input = getFlag(ctx.args, "--input") ?? getFlag(ctx.args, "-i");
322
+ if (!input) break;
323
+ inputs.push(input);
324
+ }
325
+ const mask = getFlag(ctx.args, "--mask");
326
+ const promptParts = ctx.args.slice();
327
+ ctx.args.length = 0;
328
+ const prompt = promptParts.join(" ").trim();
329
+ if (!prompt) {
330
+ throw new ImageCliError(
331
+ 'Usage: dench image edit "<prompt>" --input <path> [--input <path> ...] [--mask <path>] [--size ...] [--quality ...] [--format ...] [--save-path ...] [--output ...] [--no-write] [--json]',
332
+ );
333
+ }
334
+ if (inputs.length === 0) {
335
+ throw new ImageCliError(
336
+ "At least one --input <path> is required for image edits.",
337
+ );
338
+ }
339
+ if (inputs.length > 8) {
340
+ throw new ImageCliError(
341
+ `Too many --input paths (${inputs.length}); the gateway accepts at most 8.`,
342
+ );
343
+ }
344
+ const inputBase64 = await Promise.all(inputs.map((p) => readBase64FromPath(p)));
345
+ const maskBase64 = mask ? await readBase64FromPath(mask) : undefined;
346
+ const body = {
347
+ ...buildGenerationBody(prompt, opts),
348
+ input_images_base64: inputBase64,
349
+ ...(maskBase64 ? { mask_base64: maskBase64 } : {}),
350
+ };
351
+ const result = await callGateway("/v1/images/edits", body);
352
+ if (!result.b64_json) {
353
+ throw new ImageCliError("Gateway response did not include image bytes.");
354
+ }
355
+
356
+ let localPath: string | null = null;
357
+ if (!opts.noWrite) {
358
+ const target = resolveOutputPath(
359
+ opts.output,
360
+ result.path ?? `/image-generations/edit.${result.format ?? "png"}`,
361
+ );
362
+ const written = await writeBytesToDisk(target, result.b64_json);
363
+ localPath = written.path;
364
+ }
365
+
366
+ out(
367
+ ctx,
368
+ {
369
+ ...result,
370
+ ok: true,
371
+ localPath,
372
+ b64_json: ctx.jsonOutput && printB64 ? result.b64_json : undefined,
373
+ },
374
+ formatGenerationResult(result, localPath),
375
+ );
376
+ }
377
+
378
+ function imageHelp(): void {
379
+ console.log(`Usage: dench image <subcommand>
380
+
381
+ Generate or edit images via the Dench Cloud Gateway (gpt-image-2). Auths
382
+ with DENCH_API_KEY (baked into every sandbox automatically). Each call
383
+ writes the bytes to Convex Storage (workspace tree updates immediately)
384
+ and, when running inside a sandbox, also writes the decoded bytes to
385
+ local disk so subsequent bash commands see the file without waiting on
386
+ the daemon's downward sync.
387
+
388
+ Generate from a text prompt:
389
+ dench image generate "<prompt>" [--size 1024x1024] [--quality auto|low|medium|high]
390
+ [--format png|jpeg|webp]
391
+ [--save-path /workspace/path/file.png]
392
+ [--output <local file>] [--no-write] [--json]
393
+
394
+ Edit / remix one or more existing images (image-to-image):
395
+ dench image edit "<prompt>" --input <path> [--input <path> ...] [--mask <path>]
396
+ [--size ...] [--quality ...] [--format ...]
397
+ [--save-path ...] [--output ...] [--no-write] [--json]
398
+
399
+ Environment variables:
400
+ DENCH_API_KEY Required. Bearer token sent to the gateway.
401
+ DENCH_GATEWAY_URL Override the gateway base URL.
402
+ GATEWAY_URL Same, used when DENCH_GATEWAY_URL is unset.
403
+ DENCH_HOST If it points at localhost, defaults to
404
+ http://localhost:8787 instead of production.
405
+ DENCH_VOLUME_PATH Sandbox volume mount root (default: /workspace).
406
+ DENCH_ORG_ID Forwarded so wildcard keys can target an org.
407
+ DENCH_RUN_ID Forwarded for telemetry correlation.
408
+
409
+ Exit codes: 0 success, non-zero on gateway / network errors.`);
410
+ }
411
+
412
+ // ────────────────────────────────────────────────────────────────────────────
413
+ // Public entry point
414
+ // ────────────────────────────────────────────────────────────────────────────
415
+
416
+ export async function runImageCommand(ctx: ImageCliContext): Promise<void> {
417
+ const sub = ctx.args[0];
418
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
419
+ imageHelp();
420
+ return;
421
+ }
422
+ if (sub === "generate" || sub === "gen" || sub === "g") {
423
+ ctx.args.shift();
424
+ await runGenerateSubcommand(ctx);
425
+ return;
426
+ }
427
+ if (sub === "edit" || sub === "e") {
428
+ ctx.args.shift();
429
+ await runEditSubcommand(ctx);
430
+ return;
431
+ }
432
+ throw new ImageCliError(
433
+ `Unknown subcommand: ${sub}. Run \`dench image help\` for usage.`,
434
+ );
435
+ }
package/lib/cli-args.ts CHANGED
@@ -7,9 +7,20 @@
7
7
  * - shift(args, expected): pop the next positional arg or throw.
8
8
  * - getFlag(args, name): pull the value following `--name` and remove
9
9
  * both tokens from args. Returns undefined if the flag is missing.
10
+ * - getFlagWithAliases(args, primary, aliases): like getFlag but
11
+ * accepts multiple flag names; the first one found wins. Used to
12
+ * accept e.g. `--data` and `--fields` interchangeably so a confused
13
+ * caller doesn't silently produce blank rows.
10
14
  * - hasFlag(args, name): true iff `--name` appears (and removes it).
11
15
  * - parseJson(value): JSON.parse with a CliError-style throw on
12
16
  * failure.
17
+ * - assertNoUnknownFlags(args, command): after all known flags have
18
+ * been drained via getFlag/hasFlag, throw if any token starting
19
+ * with `--` is left in args. Catches typos like `--fields` when
20
+ * the subcommand only knows `--data` — the previous behaviour
21
+ * silently dropped the typo and produced empty writes (see
22
+ * incident in chat y5710jn4313ht2p6fwmw0m0mjn8636ya where 15 blank
23
+ * CRM rows were created before the agent realized).
13
24
  *
14
25
  * NOTE: each module wraps its own *CliError class so we don't import
15
26
  * across cli/ entry points; this module's errors throw plain Error and
@@ -24,10 +35,7 @@ export function shift(args: string[], expected: string): string {
24
35
  return value;
25
36
  }
26
37
 
27
- export function getFlag(
28
- args: string[],
29
- name: string,
30
- ): string | undefined {
38
+ export function getFlag(args: string[], name: string): string | undefined {
31
39
  const idx = args.indexOf(name);
32
40
  if (idx === -1) return undefined;
33
41
  const value = args[idx + 1];
@@ -35,6 +43,36 @@ export function getFlag(
35
43
  return value;
36
44
  }
37
45
 
46
+ export function getFlagWithAliases(
47
+ args: string[],
48
+ primary: string,
49
+ aliases: readonly string[],
50
+ ): string | undefined {
51
+ // Try each name in order; the first one found is consumed and returned.
52
+ // Aliases (and duplicate occurrences of the picked name) are still
53
+ // removed from args so unknown-flag detection downstream doesn't flag
54
+ // them as typos and so the caller can't get bitten by silent
55
+ // first-wins drift across multiple invocations.
56
+ const all = [primary, ...aliases];
57
+ let chosen: string | undefined;
58
+ for (const name of all) {
59
+ const value = getFlag(args, name);
60
+ if (value !== undefined) {
61
+ chosen = value;
62
+ break;
63
+ }
64
+ }
65
+ // Drain any remaining occurrences of every alias (including the
66
+ // primary). With a single getFlag pass per name we'd leave a
67
+ // duplicate `--fields` behind to look like an unknown flag.
68
+ for (const name of all) {
69
+ while (getFlag(args, name) !== undefined) {
70
+ // Intentional empty body: drain duplicates.
71
+ }
72
+ }
73
+ return chosen;
74
+ }
75
+
38
76
  export function hasFlag(args: string[], name: string): boolean {
39
77
  const idx = args.indexOf(name);
40
78
  if (idx === -1) return false;
@@ -50,3 +88,22 @@ export function parseJson(value: string | undefined): unknown {
50
88
  throw new CliArgError(`Invalid JSON: ${value}`);
51
89
  }
52
90
  }
91
+
92
+ export function assertNoUnknownFlags(
93
+ args: readonly string[],
94
+ command: string,
95
+ ): void {
96
+ // After every getFlag/hasFlag/getFlagWithAliases call has had a chance
97
+ // to drain known flags, any remaining token that starts with `--` is
98
+ // unknown. We surface the first one with a hint so the caller can fix
99
+ // the typo. Don't include positional `--` literals; raw `--` (used by
100
+ // some shells to terminate option parsing) is allowed.
101
+ for (const token of args) {
102
+ if (token === "--") continue;
103
+ if (token.startsWith("--")) {
104
+ throw new CliArgError(
105
+ `Unknown flag for ${command}: ${token}. Run \`dench ${command.split(" ")[0]} help\` for the full flag list.`,
106
+ );
107
+ }
108
+ }
109
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,9 @@
18
18
  "chat.ts",
19
19
  "chat-spawn.ts",
20
20
  "crm.ts",
21
+ "cron.ts",
22
+ "search.ts",
23
+ "image.ts",
21
24
  "agentKind.ts",
22
25
  "host.ts",
23
26
  "openUrl.ts",