@dench.com/cli 0.3.5 → 0.3.6
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/cron.ts +612 -0
- package/dench.ts +346 -2
- package/image.ts +435 -0
- package/package.json +4 -1
- package/search.ts +420 -0
package/dench.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
chmod,
|
|
6
|
+
mkdir,
|
|
7
|
+
readdir,
|
|
8
|
+
readFile,
|
|
9
|
+
stat,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
5
12
|
import { homedir, hostname, userInfo } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
13
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
7
14
|
import { fileURLToPath } from "node:url";
|
|
8
15
|
import { ConvexHttpClient } from "convex/browser";
|
|
9
16
|
import { makeFunctionReference } from "convex/server";
|
|
@@ -190,6 +197,11 @@ const api = {
|
|
|
190
197
|
commitFileUpload: makeFunctionReference<"mutation">(
|
|
191
198
|
"functions/files:commitFileUpload",
|
|
192
199
|
),
|
|
200
|
+
// Used by `dench stage` to record dir rows for intermediate
|
|
201
|
+
// parents (commitFileUpload only inserts leaf rows).
|
|
202
|
+
recordDirCreate: makeFunctionReference<"mutation">(
|
|
203
|
+
"functions/files:recordDirCreate",
|
|
204
|
+
),
|
|
193
205
|
getFileSignedDownloadUrl: makeFunctionReference<"action">(
|
|
194
206
|
"functions/files:getFileSignedDownloadUrl",
|
|
195
207
|
),
|
|
@@ -430,6 +442,17 @@ CRM (Daytona-native rewrite):
|
|
|
430
442
|
dench crm batch --file ops.jsonl
|
|
431
443
|
Help: dench crm help
|
|
432
444
|
|
|
445
|
+
Live web search (Exa via the Dench Cloud Gateway, auths with DENCH_API_KEY):
|
|
446
|
+
dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural] [--category news|company|people|...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--max-chars 800] [--json]
|
|
447
|
+
dench search contents <url> [<url>...] [--max-chars 4000] [--summary "..."] [--json]
|
|
448
|
+
dench search answer "<query>" [--json]
|
|
449
|
+
Help: dench search help
|
|
450
|
+
|
|
451
|
+
Image generation / editing (gpt-image-2 via the Dench Cloud Gateway):
|
|
452
|
+
dench image generate "<prompt>" [--size 1024x1024] [--quality auto|low|medium|high] [--format png|jpeg|webp] [--save-path /workspace/path/file.png] [--output <local file>] [--no-write] [--json]
|
|
453
|
+
dench image edit "<prompt>" --input <path> [--input <path> ...] [--mask <path>] [--size ...] [--quality ...] [--format ...] [--save-path ...] [--output ...] [--json]
|
|
454
|
+
Help: dench image help
|
|
455
|
+
|
|
433
456
|
Past chat threads (own + shared in this workspace):
|
|
434
457
|
dench chat list [--query <substr>] [--limit N] [--include-archived] [--json]
|
|
435
458
|
dench chat search "<query>" [--limit N] [--include-current] [--json]
|
|
@@ -456,6 +479,9 @@ returns ENOSYS on the FUSE-backed volume mount):
|
|
|
456
479
|
dench files mv <src> <dst> [--json]
|
|
457
480
|
dench files rm <path> [--recursive] [--json]
|
|
458
481
|
|
|
482
|
+
Persist scratch -> workspace (cp from /tmp into /workspace + Convex sync):
|
|
483
|
+
dench stage <local-path> [<workspace-dest>] [--clean] [--json]
|
|
484
|
+
|
|
459
485
|
Subagents (Daytona-native rewrite):
|
|
460
486
|
dench agent spawn '<goal>' [--parent-run-id <id>] [--sandbox own|share_parent] [--time-budget-ms N]
|
|
461
487
|
dench agent await --hook <token> | --children id1,id2,id3
|
|
@@ -3038,6 +3064,267 @@ async function runFilesCommand() {
|
|
|
3038
3064
|
});
|
|
3039
3065
|
}
|
|
3040
3066
|
|
|
3067
|
+
// ── dench stage ──────────────────────────────────────────────────────────
|
|
3068
|
+
//
|
|
3069
|
+
// Copy a tree from a /tmp scratch path into /workspace AND update the
|
|
3070
|
+
// Convex `fileTree` mirror so the UI tree reflects the new files
|
|
3071
|
+
// immediately. The agent's `stage_to_workspace` typed tool calls into
|
|
3072
|
+
// the same primitive server-side; this CLI variant exists so a human at
|
|
3073
|
+
// a sandbox shell (or a script) can run the same operation manually
|
|
3074
|
+
// without bouncing through the chat API.
|
|
3075
|
+
//
|
|
3076
|
+
// Why upload to Convex (instead of `cp -r local /workspace`):
|
|
3077
|
+
// /workspace is FUSE-backed (Mountpoint-S3) and the file daemon
|
|
3078
|
+
// reconciles between the volume and Convex. Uploading via Convex
|
|
3079
|
+
// guarantees the UI tree updates immediately (no wait for the
|
|
3080
|
+
// daemon's polling tick) and avoids stomping a daemon-in-flight
|
|
3081
|
+
// change on the same path.
|
|
3082
|
+
|
|
3083
|
+
const STAGE_IGNORED_BASENAMES = new Set([
|
|
3084
|
+
".DS_Store",
|
|
3085
|
+
".ds_store",
|
|
3086
|
+
"Thumbs.db",
|
|
3087
|
+
"thumbs.db",
|
|
3088
|
+
"desktop.ini",
|
|
3089
|
+
]);
|
|
3090
|
+
|
|
3091
|
+
const STAGE_IGNORED_DIRS = new Set([
|
|
3092
|
+
".git",
|
|
3093
|
+
".next",
|
|
3094
|
+
".vercel",
|
|
3095
|
+
"__MACOSX",
|
|
3096
|
+
"build",
|
|
3097
|
+
"coverage",
|
|
3098
|
+
"dist",
|
|
3099
|
+
"node_modules",
|
|
3100
|
+
]);
|
|
3101
|
+
|
|
3102
|
+
function shouldIgnoreStagePath(relPath: string): boolean {
|
|
3103
|
+
const parts = relPath.split("/").filter(Boolean);
|
|
3104
|
+
if (parts.some((p) => STAGE_IGNORED_DIRS.has(p))) return true;
|
|
3105
|
+
const base = parts.at(-1) ?? "";
|
|
3106
|
+
if (STAGE_IGNORED_BASENAMES.has(base)) return true;
|
|
3107
|
+
if (base.startsWith("._")) return true;
|
|
3108
|
+
if (
|
|
3109
|
+
base === ".env" ||
|
|
3110
|
+
(base.startsWith(".env.") &&
|
|
3111
|
+
![".env.example", ".env.sample", ".env.template"].includes(base))
|
|
3112
|
+
) {
|
|
3113
|
+
return true;
|
|
3114
|
+
}
|
|
3115
|
+
if (base.endsWith(".pem") || base.endsWith(".key")) return true;
|
|
3116
|
+
return false;
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
type DirEntryLike = {
|
|
3120
|
+
name: string;
|
|
3121
|
+
isDirectory(): boolean;
|
|
3122
|
+
isFile(): boolean;
|
|
3123
|
+
};
|
|
3124
|
+
|
|
3125
|
+
async function* walkLocalFiles(
|
|
3126
|
+
rootAbs: string,
|
|
3127
|
+
): AsyncGenerator<{ absPath: string; rel: string; size: number }> {
|
|
3128
|
+
const queue: string[] = [rootAbs];
|
|
3129
|
+
while (queue.length > 0) {
|
|
3130
|
+
const dir = queue.shift();
|
|
3131
|
+
if (!dir) continue;
|
|
3132
|
+
let entries: DirEntryLike[];
|
|
3133
|
+
try {
|
|
3134
|
+
// The Node typings for readdir({ withFileTypes: true }) lean on
|
|
3135
|
+
// generics that bun's stub doesn't surface cleanly; cast through
|
|
3136
|
+
// unknown so we get the {name, isDirectory, isFile} contract we
|
|
3137
|
+
// actually depend on without dragging Dirent generics through.
|
|
3138
|
+
entries = (await readdir(dir, {
|
|
3139
|
+
withFileTypes: true,
|
|
3140
|
+
})) as unknown as DirEntryLike[];
|
|
3141
|
+
} catch {
|
|
3142
|
+
continue;
|
|
3143
|
+
}
|
|
3144
|
+
for (const entry of entries) {
|
|
3145
|
+
const abs = join(dir, entry.name);
|
|
3146
|
+
const rel = relative(rootAbs, abs).replace(/\\/g, "/");
|
|
3147
|
+
if (shouldIgnoreStagePath(rel)) continue;
|
|
3148
|
+
if (entry.isDirectory()) {
|
|
3149
|
+
queue.push(abs);
|
|
3150
|
+
continue;
|
|
3151
|
+
}
|
|
3152
|
+
if (!entry.isFile()) continue;
|
|
3153
|
+
const stats = await stat(abs).catch(() => null);
|
|
3154
|
+
if (!stats) continue;
|
|
3155
|
+
yield { absPath: abs, rel, size: stats.size };
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
async function runStage() {
|
|
3161
|
+
const runtime = await getRuntime();
|
|
3162
|
+
const apiKey = requireFilesApiKey(runtime, "dench stage");
|
|
3163
|
+
const localRaw = positional(1);
|
|
3164
|
+
const destRaw = positional(2);
|
|
3165
|
+
if (!localRaw) {
|
|
3166
|
+
throw new CliError(
|
|
3167
|
+
"Usage: dench stage <local-path> [<workspace-dest>] [--clean] [--json]",
|
|
3168
|
+
{
|
|
3169
|
+
code: "stage_usage",
|
|
3170
|
+
nextActions: [
|
|
3171
|
+
"Pass an absolute local path (typically under /tmp) to stage from.",
|
|
3172
|
+
"Optional second arg is the /workspace destination (defaults to /<basename>).",
|
|
3173
|
+
],
|
|
3174
|
+
},
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
const localAbs = resolve(localRaw);
|
|
3178
|
+
if (localAbs.startsWith("/workspace/") || localAbs === "/workspace") {
|
|
3179
|
+
throw new CliError(
|
|
3180
|
+
`localPath must not be inside /workspace (got ${localAbs}). Stage from /tmp scratch instead.`,
|
|
3181
|
+
{ code: "stage_local_in_workspace" },
|
|
3182
|
+
);
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
let localStat: Awaited<ReturnType<typeof stat>>;
|
|
3186
|
+
try {
|
|
3187
|
+
localStat = await stat(localAbs);
|
|
3188
|
+
} catch (error) {
|
|
3189
|
+
throw new CliError(
|
|
3190
|
+
`Local path not found: ${localAbs} (${error instanceof Error ? error.message : String(error)})`,
|
|
3191
|
+
{ code: "stage_local_missing" },
|
|
3192
|
+
);
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
// Default workspace destination = /<basename of source>. The agent
|
|
3196
|
+
// typed tool requires explicit dest; the CLI gives a friendly default
|
|
3197
|
+
// because humans usually want "stage this dir to /workspace/<dir>".
|
|
3198
|
+
const destWorkspace = destRaw
|
|
3199
|
+
? normalizeFilesPath(destRaw)
|
|
3200
|
+
: normalizeFilesPath(`/${basename(localAbs)}`);
|
|
3201
|
+
const clean = hasFlag("--clean");
|
|
3202
|
+
// `--json` is consumed at the top of the file into the module-level
|
|
3203
|
+
// `json` flag (and filtered out of `args`); read that, not `hasFlag`.
|
|
3204
|
+
const jsonOut = json;
|
|
3205
|
+
|
|
3206
|
+
if (clean) {
|
|
3207
|
+
// Wipe Convex rows under destWorkspace so a clean install really
|
|
3208
|
+
// is clean. The daemon will mirror the deletes onto the volume
|
|
3209
|
+
// on its next reconcile tick.
|
|
3210
|
+
const existing = await listConvexTreeRecursive(
|
|
3211
|
+
runtime,
|
|
3212
|
+
apiKey,
|
|
3213
|
+
destWorkspace,
|
|
3214
|
+
);
|
|
3215
|
+
const fileRows = existing.filter((r) => !r.isDir);
|
|
3216
|
+
const dirRows = existing.filter((r) => r.isDir);
|
|
3217
|
+
dirRows.sort((a, b) => b.path.length - a.path.length);
|
|
3218
|
+
for (const row of fileRows) {
|
|
3219
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
3220
|
+
path: row.path,
|
|
3221
|
+
apiKey,
|
|
3222
|
+
} as never);
|
|
3223
|
+
}
|
|
3224
|
+
for (const row of dirRows) {
|
|
3225
|
+
await runtime.client.mutation(api.functions.files.deleteFile, {
|
|
3226
|
+
path: row.path,
|
|
3227
|
+
apiKey,
|
|
3228
|
+
} as never);
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
const staged: Array<{ from: string; to: string; bytes: number }> = [];
|
|
3233
|
+
const recordedDirs = new Set<string>();
|
|
3234
|
+
let bytesStaged = 0;
|
|
3235
|
+
|
|
3236
|
+
// `commitFileUpload` only inserts the leaf file row — it does NOT
|
|
3237
|
+
// create rows for the file's intermediate parent dirs. Without
|
|
3238
|
+
// those, `listTree(prefix)` won't surface a `sub` directory in the
|
|
3239
|
+
// UI tree even when `sub/notes.txt` exists. We record each unique
|
|
3240
|
+
// parent dir explicitly via `recordDirCreate` so the right-pane
|
|
3241
|
+
// tree mirrors the staged structure 1:1.
|
|
3242
|
+
async function ensureDirRowsFor(filePath: string): Promise<void> {
|
|
3243
|
+
let parent = parentOfPath(filePath);
|
|
3244
|
+
while (parent && parent !== "/" && !recordedDirs.has(parent)) {
|
|
3245
|
+
recordedDirs.add(parent);
|
|
3246
|
+
try {
|
|
3247
|
+
await runtime.client.mutation(api.functions.files.recordDirCreate, {
|
|
3248
|
+
path: parent,
|
|
3249
|
+
apiKey,
|
|
3250
|
+
} as never);
|
|
3251
|
+
} catch (error) {
|
|
3252
|
+
// Best-effort: a transient failure on a single dir shouldn't
|
|
3253
|
+
// fail the entire stage. The leaf files are already persisted;
|
|
3254
|
+
// worst case the daemon's reconciler picks up the dir on its
|
|
3255
|
+
// next tick.
|
|
3256
|
+
if (!jsonOut) {
|
|
3257
|
+
process.stderr.write(
|
|
3258
|
+
`dench stage: warn — recordDirCreate ${parent} failed: ${
|
|
3259
|
+
error instanceof Error ? error.message : String(error)
|
|
3260
|
+
}\n`,
|
|
3261
|
+
);
|
|
3262
|
+
}
|
|
3263
|
+
}
|
|
3264
|
+
parent = parentOfPath(parent);
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
if (localStat.isFile()) {
|
|
3269
|
+
const bytes = await readFile(localAbs);
|
|
3270
|
+
const dest = destWorkspace;
|
|
3271
|
+
await ensureDirRowsFor(dest);
|
|
3272
|
+
await uploadConvexFileBytes(runtime, apiKey, {
|
|
3273
|
+
path: dest,
|
|
3274
|
+
bytes: new Uint8Array(bytes),
|
|
3275
|
+
});
|
|
3276
|
+
staged.push({ from: localAbs, to: dest, bytes: bytes.byteLength });
|
|
3277
|
+
bytesStaged += bytes.byteLength;
|
|
3278
|
+
} else if (localStat.isDirectory()) {
|
|
3279
|
+
for await (const entry of walkLocalFiles(localAbs)) {
|
|
3280
|
+
const dest = entry.rel
|
|
3281
|
+
? normalizeFilesPath(`${destWorkspace}/${entry.rel}`)
|
|
3282
|
+
: destWorkspace;
|
|
3283
|
+
await ensureDirRowsFor(dest);
|
|
3284
|
+
const bytes = await readFile(entry.absPath);
|
|
3285
|
+
await uploadConvexFileBytes(runtime, apiKey, {
|
|
3286
|
+
path: dest,
|
|
3287
|
+
bytes: new Uint8Array(bytes),
|
|
3288
|
+
});
|
|
3289
|
+
staged.push({
|
|
3290
|
+
from: entry.absPath,
|
|
3291
|
+
to: dest,
|
|
3292
|
+
bytes: bytes.byteLength,
|
|
3293
|
+
});
|
|
3294
|
+
bytesStaged += bytes.byteLength;
|
|
3295
|
+
if (!jsonOut && staged.length % 25 === 0) {
|
|
3296
|
+
process.stdout.write(
|
|
3297
|
+
`dench stage: ${staged.length} files, ${bytesStaged}B…\n`,
|
|
3298
|
+
);
|
|
3299
|
+
}
|
|
3300
|
+
}
|
|
3301
|
+
} else {
|
|
3302
|
+
throw new CliError(
|
|
3303
|
+
`Local path is neither a file nor a directory: ${localAbs}`,
|
|
3304
|
+
{ code: "stage_local_unsupported" },
|
|
3305
|
+
);
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
if (jsonOut) {
|
|
3309
|
+
print({
|
|
3310
|
+
ok: true,
|
|
3311
|
+
localPath: localAbs,
|
|
3312
|
+
workspacePath: destWorkspace,
|
|
3313
|
+
filesStaged: staged.length,
|
|
3314
|
+
bytesStaged,
|
|
3315
|
+
files: staged,
|
|
3316
|
+
clean,
|
|
3317
|
+
});
|
|
3318
|
+
return;
|
|
3319
|
+
}
|
|
3320
|
+
|
|
3321
|
+
process.stdout.write(
|
|
3322
|
+
`dench stage: ${staged.length} files (${bytesStaged}B) staged from ${localAbs} -> ${destWorkspace}${
|
|
3323
|
+
clean ? " [clean install]" : ""
|
|
3324
|
+
}\n`,
|
|
3325
|
+
);
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3041
3328
|
function parentOfPath(input: string): string {
|
|
3042
3329
|
const normalized = normalizeFilesPath(input);
|
|
3043
3330
|
if (normalized === "/") return "/";
|
|
@@ -3210,6 +3497,34 @@ async function main() {
|
|
|
3210
3497
|
return;
|
|
3211
3498
|
}
|
|
3212
3499
|
|
|
3500
|
+
if (command === "stage") {
|
|
3501
|
+
await runStage();
|
|
3502
|
+
return;
|
|
3503
|
+
}
|
|
3504
|
+
|
|
3505
|
+
if (command === "search") {
|
|
3506
|
+
// `dench search` talks straight to the gateway over HTTP using
|
|
3507
|
+
// DENCH_API_KEY; it deliberately bypasses requireSessionRuntime so
|
|
3508
|
+
// it works in any sandbox / CI environment that has the key.
|
|
3509
|
+
const subArgs = args.slice(args.indexOf("search") + 1);
|
|
3510
|
+
const filteredSubArgs = subArgs.filter((a) => a !== "--json");
|
|
3511
|
+
const { runSearchCommand } = await import("./search");
|
|
3512
|
+
await runSearchCommand({ args: filteredSubArgs, jsonOutput: json });
|
|
3513
|
+
return;
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
if (command === "image") {
|
|
3517
|
+
// `dench image generate|edit` mirrors `dench search`: straight HTTP
|
|
3518
|
+
// to the gateway with DENCH_API_KEY, no Convex session required.
|
|
3519
|
+
// The gateway writes the bytes to Convex Storage on success; we
|
|
3520
|
+
// also drop them on local disk so the next bash command sees them.
|
|
3521
|
+
const subArgs = args.slice(args.indexOf("image") + 1);
|
|
3522
|
+
const filteredSubArgs = subArgs.filter((a) => a !== "--json");
|
|
3523
|
+
const { runImageCommand } = await import("./image");
|
|
3524
|
+
await runImageCommand({ args: filteredSubArgs, jsonOutput: json });
|
|
3525
|
+
return;
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3213
3528
|
if (command === "chat") {
|
|
3214
3529
|
const subArgs = args.slice(args.indexOf("chat") + 1);
|
|
3215
3530
|
const sub = subArgs[0];
|
|
@@ -3298,6 +3613,35 @@ async function main() {
|
|
|
3298
3613
|
return;
|
|
3299
3614
|
}
|
|
3300
3615
|
|
|
3616
|
+
if (command === "cron") {
|
|
3617
|
+
const subArgs = args.slice(args.indexOf("cron") + 1);
|
|
3618
|
+
const sub = subArgs[0];
|
|
3619
|
+
// Help is a no-auth path so users can read the surface without a
|
|
3620
|
+
// session — mirrors `dench crm help`.
|
|
3621
|
+
if (!sub || sub === "help" || sub === "--help") {
|
|
3622
|
+
const { runCronCommand } = await import("./cron");
|
|
3623
|
+
// biome-ignore lint/suspicious/noExplicitAny: help-only stub, never queries
|
|
3624
|
+
await runCronCommand({ convex: {} as any, args: subArgs });
|
|
3625
|
+
return;
|
|
3626
|
+
}
|
|
3627
|
+
const { runCronCommand } = await import("./cron");
|
|
3628
|
+
const runtime = await getRuntime();
|
|
3629
|
+
// Cron CRUD is org-scoped — accept either an agent session token
|
|
3630
|
+
// (`dch_agent_*` from `dench login`) or a unified Dench API key
|
|
3631
|
+
// (`DENCH_API_KEY` baked into sandbox envVars). Server-side
|
|
3632
|
+
// `requireCronAccess` (convex/lib/cronAccess.ts) dispatches on the
|
|
3633
|
+
// bearer prefix and resolves the org for both. The Bearer token
|
|
3634
|
+
// must carry `crons:read` (queries) or `crons:write` (mutations)
|
|
3635
|
+
// scope, or the wildcard `*`.
|
|
3636
|
+
const authedRuntime = requireAuthenticatedRuntime(runtime, "dench cron");
|
|
3637
|
+
await runCronCommand({
|
|
3638
|
+
convex: authedRuntime.client,
|
|
3639
|
+
args: subArgs,
|
|
3640
|
+
sessionToken: authedRuntime.sessionToken,
|
|
3641
|
+
});
|
|
3642
|
+
return;
|
|
3643
|
+
}
|
|
3644
|
+
|
|
3301
3645
|
if (command === "login") {
|
|
3302
3646
|
await login();
|
|
3303
3647
|
return;
|