@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/crm.ts +188 -87
- package/cron.ts +612 -0
- package/dench.ts +346 -2
- package/image.ts +435 -0
- package/lib/cli-args.ts +61 -4
- package/package.json +4 -1
- package/search.ts +420 -0
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.
|
|
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",
|