@dench.com/cli 0.3.4 → 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/search.ts ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * `dench search` — live web access through the Dench Cloud Gateway.
3
+ *
4
+ * The gateway exposes Exa Search at three routes; we mirror them as
5
+ * three subcommands and authenticate with the `DENCH_API_KEY` baked
6
+ * into every sandbox at create time. Unlike most other CLI surfaces
7
+ * this one bypasses Convex entirely — it speaks straight HTTP to the
8
+ * gateway, so it works in any environment that has the env var
9
+ * (sandboxes, CI, local dev with `DENCH_API_KEY` exported manually).
10
+ *
11
+ * Subcommands:
12
+ * search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural]
13
+ * [--category news|company|people|...]
14
+ * [--include-domains a.com,b.com] [--exclude-domains c.com]
15
+ * [--max-chars 800] [--json]
16
+ * search contents <url> [<url>...] [--max-chars 4000] [--summary "..."] [--json]
17
+ * search answer "<query>" [--json]
18
+ */
19
+
20
+ import {
21
+ CliArgError,
22
+ getFlag,
23
+ hasFlag,
24
+ shift as shiftRaw,
25
+ } from "./lib/cli-args";
26
+
27
+ type SearchCliContext = {
28
+ args: string[];
29
+ jsonOutput: boolean;
30
+ };
31
+
32
+ class SearchCliError extends Error {}
33
+
34
+ function shift(args: string[], expected: string): string {
35
+ try {
36
+ return shiftRaw(args, expected);
37
+ } catch (error) {
38
+ if (error instanceof CliArgError) {
39
+ throw new SearchCliError(error.message);
40
+ }
41
+ throw error;
42
+ }
43
+ }
44
+
45
+ function parsePositiveInt(name: string, raw: string | undefined): number | undefined {
46
+ if (raw === undefined) return undefined;
47
+ const parsed = Number(raw);
48
+ if (!Number.isFinite(parsed) || parsed <= 0) {
49
+ throw new SearchCliError(`Invalid ${name} value: ${raw}`);
50
+ }
51
+ return Math.floor(parsed);
52
+ }
53
+
54
+ function splitCsv(raw: string | undefined): string[] | undefined {
55
+ if (raw === undefined) return undefined;
56
+ const parts = raw
57
+ .split(",")
58
+ .map((entry) => entry.trim())
59
+ .filter((entry) => entry.length > 0);
60
+ return parts.length > 0 ? parts : undefined;
61
+ }
62
+
63
+ const ALLOWED_TYPES = new Set([
64
+ "auto",
65
+ "neural",
66
+ "fast",
67
+ "deep",
68
+ "deep-reasoning",
69
+ "instant",
70
+ ]);
71
+
72
+ const ALLOWED_CATEGORIES = new Set([
73
+ "company",
74
+ "research paper",
75
+ "news",
76
+ "personal site",
77
+ "financial report",
78
+ "people",
79
+ ]);
80
+
81
+ function resolveGatewayBaseUrl(): string {
82
+ const explicit =
83
+ process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
84
+ if (explicit) return explicit.replace(/\/+$/, "");
85
+ // Local dev: the gateway listens on :8787 (matches gateway/src/index.ts
86
+ // and the dev-server skill). Anything that smells like localhost in
87
+ // DENCH_HOST routes to the local gateway too so `dench --host
88
+ // http://localhost:3000 search ...` does the right thing.
89
+ const host = process.env.DENCH_HOST?.trim();
90
+ if (host && /localhost|127\.0\.0\.1/.test(host)) {
91
+ return "http://localhost:8787";
92
+ }
93
+ return "https://gateway.merseoriginals.com";
94
+ }
95
+
96
+ function requireApiKey(): string {
97
+ const apiKey = process.env.DENCH_API_KEY?.trim();
98
+ if (!apiKey) {
99
+ throw new SearchCliError(
100
+ "DENCH_API_KEY is not set. Inside a Dench sandbox it is baked into " +
101
+ "the env automatically; outside, export it manually or run " +
102
+ "`dench login` first.",
103
+ );
104
+ }
105
+ return apiKey;
106
+ }
107
+
108
+ async function callGateway(
109
+ path: "/v1/search" | "/v1/search/contents" | "/v1/search/answer",
110
+ body: Record<string, unknown>,
111
+ ): Promise<unknown> {
112
+ const apiKey = requireApiKey();
113
+ const baseUrl = resolveGatewayBaseUrl();
114
+ const response = await fetch(`${baseUrl}${path}`, {
115
+ method: "POST",
116
+ headers: {
117
+ "content-type": "application/json",
118
+ Authorization: `Bearer ${apiKey}`,
119
+ },
120
+ body: JSON.stringify(body),
121
+ });
122
+ if (!response.ok) {
123
+ let detail = "";
124
+ try {
125
+ detail = await response.text();
126
+ } catch {
127
+ // ignore
128
+ }
129
+ const truncated = detail.length > 500 ? `${detail.slice(0, 500)}…` : detail;
130
+ throw new SearchCliError(
131
+ `Gateway ${path} returned ${response.status}: ${truncated || response.statusText}`,
132
+ );
133
+ }
134
+ return response.json();
135
+ }
136
+
137
+ // ────────────────────────────────────────────────────────────────────────────
138
+ // Output formatting
139
+ // ────────────────────────────────────────────────────────────────────────────
140
+
141
+ function asString(value: unknown): string | undefined {
142
+ return typeof value === "string" ? value : undefined;
143
+ }
144
+
145
+ function asNumber(value: unknown): number | undefined {
146
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
147
+ }
148
+
149
+ function asObject(value: unknown): Record<string, unknown> | undefined {
150
+ return value && typeof value === "object" && !Array.isArray(value)
151
+ ? (value as Record<string, unknown>)
152
+ : undefined;
153
+ }
154
+
155
+ function asArray(value: unknown): unknown[] {
156
+ return Array.isArray(value) ? value : [];
157
+ }
158
+
159
+ function trimSnippet(text: string, maxChars: number): string {
160
+ const collapsed = text.replace(/\s+/g, " ").trim();
161
+ if (collapsed.length <= maxChars) return collapsed;
162
+ return `${collapsed.slice(0, maxChars - 1)}…`;
163
+ }
164
+
165
+ function formatHostname(url: string): string {
166
+ try {
167
+ return new URL(url).hostname.replace(/^www\./, "");
168
+ } catch {
169
+ return url;
170
+ }
171
+ }
172
+
173
+ function formatSearchResults(payload: unknown, limit?: number): string {
174
+ const root = asObject(payload);
175
+ if (!root) return "(no results)";
176
+ const results = asArray(root.results);
177
+ const lines: string[] = [];
178
+ const auto = asString(root.autopromptString);
179
+ if (auto) {
180
+ lines.push(`Auto-prompt: ${auto}`);
181
+ lines.push("");
182
+ }
183
+ if (results.length === 0) {
184
+ lines.push("(no results)");
185
+ return lines.join("\n");
186
+ }
187
+ const cap = limit !== undefined ? Math.min(results.length, limit) : results.length;
188
+ for (let i = 0; i < cap; i++) {
189
+ const result = asObject(results[i]);
190
+ if (!result) continue;
191
+ const title = asString(result.title) ?? "(untitled)";
192
+ const url = asString(result.url) ?? "";
193
+ const host = formatHostname(url);
194
+ const published = asString(result.publishedDate);
195
+ const author = asString(result.author);
196
+ const meta = [host, published, author].filter(Boolean).join(" · ");
197
+ const snippet =
198
+ asString(result.text) ??
199
+ (Array.isArray(result.highlights)
200
+ ? result.highlights.filter((h): h is string => typeof h === "string").join(" ")
201
+ : undefined) ??
202
+ asString(result.summary);
203
+ lines.push(`${i + 1}. ${title}`);
204
+ if (url) lines.push(` ${url}`);
205
+ if (meta) lines.push(` ${meta}`);
206
+ if (snippet) lines.push(` ${trimSnippet(snippet, 240)}`);
207
+ lines.push("");
208
+ }
209
+ const cost = asNumber(asObject(root.costDollars)?.total);
210
+ if (cost !== undefined) {
211
+ lines.push(`Cost (USD): $${cost.toFixed(4)}`);
212
+ }
213
+ return lines.join("\n").trimEnd();
214
+ }
215
+
216
+ function formatContentsResults(payload: unknown): string {
217
+ const root = asObject(payload);
218
+ if (!root) return "(no contents)";
219
+ const results = asArray(root.results);
220
+ const lines: string[] = [];
221
+ if (results.length === 0) {
222
+ return "(no contents)";
223
+ }
224
+ for (let i = 0; i < results.length; i++) {
225
+ const result = asObject(results[i]);
226
+ if (!result) continue;
227
+ const title = asString(result.title) ?? "(untitled)";
228
+ const url = asString(result.url) ?? "";
229
+ lines.push(`${i + 1}. ${title}`);
230
+ if (url) lines.push(` ${url}`);
231
+ const summary = asString(result.summary);
232
+ if (summary) {
233
+ lines.push(" Summary:");
234
+ lines.push(...indent(trimSnippet(summary, 1200), " "));
235
+ }
236
+ const text = asString(result.text);
237
+ if (text) {
238
+ lines.push(" Content:");
239
+ lines.push(...indent(trimSnippet(text, 2000), " "));
240
+ }
241
+ lines.push("");
242
+ }
243
+ return lines.join("\n").trimEnd();
244
+ }
245
+
246
+ function formatAnswer(payload: unknown): string {
247
+ const root = asObject(payload);
248
+ if (!root) return "(no answer)";
249
+ const answer = asString(root.answer) ?? "";
250
+ const citations = asArray(root.citations);
251
+ const lines: string[] = [];
252
+ if (answer) {
253
+ lines.push(answer.trim());
254
+ lines.push("");
255
+ }
256
+ if (citations.length > 0) {
257
+ lines.push("Citations:");
258
+ for (let i = 0; i < citations.length; i++) {
259
+ const citation = asObject(citations[i]);
260
+ if (!citation) continue;
261
+ const title = asString(citation.title) ?? "(untitled)";
262
+ const url = asString(citation.url) ?? "";
263
+ lines.push(` ${i + 1}. ${title}`);
264
+ if (url) lines.push(` ${url}`);
265
+ }
266
+ }
267
+ return lines.join("\n").trimEnd() || "(no answer)";
268
+ }
269
+
270
+ function indent(text: string, prefix: string): string[] {
271
+ return text.split("\n").map((line) => `${prefix}${line}`);
272
+ }
273
+
274
+ function out(ctx: SearchCliContext, payload: unknown, formatted: string): void {
275
+ if (ctx.jsonOutput) {
276
+ console.log(JSON.stringify(payload, null, 2));
277
+ return;
278
+ }
279
+ console.log(formatted);
280
+ }
281
+
282
+ // ────────────────────────────────────────────────────────────────────────────
283
+ // Subcommands
284
+ // ────────────────────────────────────────────────────────────────────────────
285
+
286
+ async function runSearchSubcommand(ctx: SearchCliContext): Promise<void> {
287
+ const numResults = parsePositiveInt("--num-results", getFlag(ctx.args, "--num-results"));
288
+ const type = getFlag(ctx.args, "--type");
289
+ if (type !== undefined && !ALLOWED_TYPES.has(type)) {
290
+ throw new SearchCliError(
291
+ `Invalid --type: ${type}. Allowed: ${[...ALLOWED_TYPES].join(", ")}`,
292
+ );
293
+ }
294
+ const category = getFlag(ctx.args, "--category");
295
+ if (category !== undefined && !ALLOWED_CATEGORIES.has(category)) {
296
+ throw new SearchCliError(
297
+ `Invalid --category: ${category}. Allowed: ${[...ALLOWED_CATEGORIES].join(", ")}`,
298
+ );
299
+ }
300
+ const includeDomains = splitCsv(getFlag(ctx.args, "--include-domains"));
301
+ const excludeDomains = splitCsv(getFlag(ctx.args, "--exclude-domains"));
302
+ const maxChars =
303
+ parsePositiveInt("--max-chars", getFlag(ctx.args, "--max-chars")) ?? 800;
304
+ const queryParts = ctx.args.slice();
305
+ ctx.args.length = 0;
306
+ const query = queryParts.join(" ").trim();
307
+ if (!query) {
308
+ throw new SearchCliError(
309
+ 'Usage: dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning] [--category ...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--max-chars 800] [--json]',
310
+ );
311
+ }
312
+ const body: Record<string, unknown> = {
313
+ query,
314
+ numResults: numResults ?? 10,
315
+ type: type ?? "auto",
316
+ contents: {
317
+ text: { maxCharacters: maxChars },
318
+ highlights: { maxCharacters: 200 },
319
+ },
320
+ };
321
+ if (category) body.category = category;
322
+ if (includeDomains) body.includeDomains = includeDomains;
323
+ if (excludeDomains) body.excludeDomains = excludeDomains;
324
+ const payload = await callGateway("/v1/search", body);
325
+ out(ctx, payload, formatSearchResults(payload, numResults));
326
+ }
327
+
328
+ async function runContentsSubcommand(ctx: SearchCliContext): Promise<void> {
329
+ const maxChars = parsePositiveInt("--max-chars", getFlag(ctx.args, "--max-chars"));
330
+ const summary = getFlag(ctx.args, "--summary");
331
+ const urls = ctx.args.slice();
332
+ ctx.args.length = 0;
333
+ if (urls.length === 0) {
334
+ throw new SearchCliError(
335
+ 'Usage: dench search contents <url> [<url>...] [--max-chars 4000] [--summary "..."] [--json]',
336
+ );
337
+ }
338
+ if (urls.length > 10) {
339
+ throw new SearchCliError(
340
+ `Too many URLs (${urls.length}); the gateway accepts at most 10 per call.`,
341
+ );
342
+ }
343
+ const body: Record<string, unknown> = {
344
+ urls,
345
+ text: maxChars ? { maxCharacters: maxChars } : true,
346
+ highlights: { maxCharacters: 200 },
347
+ };
348
+ if (summary) body.summary = { query: summary };
349
+ const payload = await callGateway("/v1/search/contents", body);
350
+ out(ctx, payload, formatContentsResults(payload));
351
+ }
352
+
353
+ async function runAnswerSubcommand(ctx: SearchCliContext): Promise<void> {
354
+ const queryParts = ctx.args.slice();
355
+ ctx.args.length = 0;
356
+ const query = queryParts.join(" ").trim();
357
+ if (!query) {
358
+ throw new SearchCliError(
359
+ 'Usage: dench search answer "<query>" [--json]',
360
+ );
361
+ }
362
+ const payload = await callGateway("/v1/search/answer", {
363
+ query,
364
+ text: true,
365
+ stream: false,
366
+ });
367
+ out(ctx, payload, formatAnswer(payload));
368
+ }
369
+
370
+ function searchHelp(): void {
371
+ console.log(`Usage: dench search <subcommand>
372
+
373
+ Live web access through the Dench Cloud Gateway (Exa Search). Auths
374
+ with DENCH_API_KEY (baked into every sandbox automatically).
375
+
376
+ Search the web (default subcommand):
377
+ dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural]
378
+ [--category news|company|people|...]
379
+ [--include-domains a.com,b.com] [--exclude-domains c.com]
380
+ [--max-chars 800] [--json]
381
+
382
+ Deep-read one or more URLs (Exa /search/contents):
383
+ dench search contents <url> [<url>...] [--max-chars 4000] [--summary "..."] [--json]
384
+
385
+ One-shot synthesised answer with citations (Exa /search/answer):
386
+ dench search answer "<query>" [--json]
387
+
388
+ Environment variables:
389
+ DENCH_API_KEY Required. Bearer token sent to the gateway.
390
+ DENCH_GATEWAY_URL Override the gateway base URL.
391
+ GATEWAY_URL Same, used when DENCH_GATEWAY_URL is unset.
392
+ DENCH_HOST If it points at localhost, defaults to
393
+ http://localhost:8787 instead of production.
394
+
395
+ Exit codes: 0 success, non-zero on gateway / network errors.`);
396
+ }
397
+
398
+ // ────────────────────────────────────────────────────────────────────────────
399
+ // Public entry point
400
+ // ────────────────────────────────────────────────────────────────────────────
401
+
402
+ export async function runSearchCommand(ctx: SearchCliContext): Promise<void> {
403
+ const sub = ctx.args[0];
404
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
405
+ searchHelp();
406
+ return;
407
+ }
408
+ if (sub === "contents") {
409
+ ctx.args.shift();
410
+ await runContentsSubcommand(ctx);
411
+ return;
412
+ }
413
+ if (sub === "answer") {
414
+ ctx.args.shift();
415
+ await runAnswerSubcommand(ctx);
416
+ return;
417
+ }
418
+ // No explicit subcommand — treat the rest of the argv as the query.
419
+ await runSearchSubcommand(ctx);
420
+ }