@dench.com/cli 2.7.8 → 2.7.9-staging.86

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/dench.ts CHANGED
@@ -517,6 +517,15 @@ Live web search (Exa via the Dench Gateway, auths with the active \`dench signin
517
517
  dench search answer "<query>" [--json]
518
518
  Help: dench search help
519
519
 
520
+ Dench web (page read, brand, sitemap, crawl, screenshot, extract — prefer these over writing your own scraper):
521
+ dench web scrape <url> [--json]
522
+ dench web brand <domain|email> [--json]
523
+ dench web sitemap <domain> [--limit N] [--json]
524
+ dench web crawl <url> [--max-pages N] [--json]
525
+ dench web screenshot <url> [--full-page] [--json]
526
+ dench web extract <url> --schema '<json>' [--json]
527
+ Help: dench web help
528
+
520
529
  Cloud browser (runs entirely in Dench Cloud):
521
530
  dench browser open [url] [--json]
522
531
  dench browser inspect [--json]
@@ -4240,6 +4249,26 @@ async function main() {
4240
4249
  return;
4241
4250
  }
4242
4251
 
4252
+ if (command === "web") {
4253
+ const subArgs = args.slice(args.indexOf("web") + 1);
4254
+ const filteredSubArgs = subArgs.filter((a) => a !== "--json");
4255
+ const { runWebCommand, webHelp } = await import("./web");
4256
+ const sub = filteredSubArgs[0];
4257
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
4258
+ webHelp();
4259
+ return;
4260
+ }
4261
+ const runtime = await getRuntime();
4262
+ const gatewayAuth = await resolveGatewayCommandAuth(runtime, "dench web");
4263
+ await runWebCommand({
4264
+ args: filteredSubArgs,
4265
+ jsonOutput: json,
4266
+ bearerToken: gatewayAuth.bearerToken,
4267
+ gatewayBaseUrl: gatewayAuth.gatewayBaseUrl,
4268
+ });
4269
+ return;
4270
+ }
4271
+
4243
4272
  if (command === "search") {
4244
4273
  const subArgs = args.slice(args.indexOf("search") + 1);
4245
4274
  const filteredSubArgs = subArgs.filter((a) => a !== "--json");
@@ -2963,6 +2963,29 @@ export const requestExamples: Record<string, unknown> = {
2963
2963
  schedule: { kind: "every", everyMs: 86_400_000 },
2964
2964
  },
2965
2965
  "search.web": { query: "latest AI CRM workflows", numResults: 5 },
2966
+ "web.scrape": {
2967
+ input: { url: "https://example.com", useMainContentOnly: true },
2968
+ },
2969
+ "web.brand": { input: { domain: "stripe.com" } },
2970
+ "web.sitemap": { input: { domain: "example.com", maxLinks: 1000 } },
2971
+ "web.crawl": {
2972
+ input: { url: "https://example.com", maxPages: 15, useMainContentOnly: true },
2973
+ },
2974
+ "web.screenshot": {
2975
+ input: { directUrl: "https://example.com", fullScreenshot: false },
2976
+ },
2977
+ "web.extract": {
2978
+ input: {
2979
+ url: "https://example.com",
2980
+ schema: { type: "object", properties: { title: { type: "string" } } },
2981
+ },
2982
+ },
2983
+ "web.product": { input: { url: "https://example.com/product" } },
2984
+ "web.competitors": { input: { domain: "stripe.com", numCompetitors: 5 } },
2985
+ "web.styleguide": { input: { domain: "stripe.com" } },
2986
+ "web.fonts": { input: { domain: "stripe.com" } },
2987
+ "web.classify": { input: { input: "stripe.com" } },
2988
+ "web.brandSearch": { input: { query: "stripe" } },
2966
2989
  "image.generate": {
2967
2990
  prompt: "A minimal flat-vector app icon",
2968
2991
  size: "1024x1024",
@@ -2220,6 +2220,138 @@ const rawApiOperations = [
2220
2220
  responseSchema: anyResponse,
2221
2221
  backend: gateway("POST", "/search/answer"),
2222
2222
  }),
2223
+ op({
2224
+ id: "web.scrape",
2225
+ group: "gateway",
2226
+ summary: "Read a page as clean Markdown through Dench web.",
2227
+ cli: "dench web scrape",
2228
+ method: "POST",
2229
+ path: "/context/web-scrape-markdown",
2230
+ requestSchema: anyObject,
2231
+ responseSchema: anyResponse,
2232
+ backend: gateway("POST", "/context/web-scrape-markdown"),
2233
+ }),
2234
+ op({
2235
+ id: "web.brand",
2236
+ group: "gateway",
2237
+ summary: "Look up a brand kit from a domain or email.",
2238
+ cli: "dench web brand",
2239
+ method: "POST",
2240
+ path: "/context/brand-retrieve",
2241
+ requestSchema: anyObject,
2242
+ responseSchema: anyResponse,
2243
+ backend: gateway("POST", "/context/brand-retrieve"),
2244
+ }),
2245
+ op({
2246
+ id: "web.sitemap",
2247
+ group: "gateway",
2248
+ summary: "List sitemap URLs for a domain.",
2249
+ cli: "dench web sitemap",
2250
+ method: "POST",
2251
+ path: "/context/web-sitemap",
2252
+ requestSchema: anyObject,
2253
+ responseSchema: anyResponse,
2254
+ backend: gateway("POST", "/context/web-sitemap"),
2255
+ }),
2256
+ op({
2257
+ id: "web.crawl",
2258
+ group: "gateway",
2259
+ summary: "Crawl a site to Markdown (capped).",
2260
+ cli: "dench web crawl",
2261
+ method: "POST",
2262
+ path: "/context/web-crawl",
2263
+ requestSchema: anyObject,
2264
+ responseSchema: anyResponse,
2265
+ backend: gateway("POST", "/context/web-crawl"),
2266
+ }),
2267
+ op({
2268
+ id: "web.screenshot",
2269
+ group: "gateway",
2270
+ summary: "Capture a rendered page screenshot.",
2271
+ cli: "dench web screenshot",
2272
+ method: "POST",
2273
+ path: "/context/web-screenshot",
2274
+ requestSchema: anyObject,
2275
+ responseSchema: anyResponse,
2276
+ backend: gateway("POST", "/context/web-screenshot"),
2277
+ }),
2278
+ op({
2279
+ id: "web.extract",
2280
+ group: "gateway",
2281
+ summary: "Extract typed fields from a page using a JSON Schema.",
2282
+ cli: "dench web extract",
2283
+ method: "POST",
2284
+ path: "/context/web-extract",
2285
+ requestSchema: anyObject,
2286
+ responseSchema: anyResponse,
2287
+ backend: gateway("POST", "/context/web-extract"),
2288
+ }),
2289
+ op({
2290
+ id: "web.product",
2291
+ group: "gateway",
2292
+ summary: "Extract a product listing from a URL.",
2293
+ cli: "dench web product",
2294
+ method: "POST",
2295
+ path: "/context/web-product",
2296
+ requestSchema: anyObject,
2297
+ responseSchema: anyResponse,
2298
+ backend: gateway("POST", "/context/web-product"),
2299
+ }),
2300
+ op({
2301
+ id: "web.competitors",
2302
+ group: "gateway",
2303
+ summary: "Find competitors of a domain.",
2304
+ cli: "dench web competitors",
2305
+ method: "POST",
2306
+ path: "/context/web-competitors",
2307
+ requestSchema: anyObject,
2308
+ responseSchema: anyResponse,
2309
+ backend: gateway("POST", "/context/web-competitors"),
2310
+ }),
2311
+ op({
2312
+ id: "web.styleguide",
2313
+ group: "gateway",
2314
+ summary: "Read a brand styleguide from a domain.",
2315
+ cli: "dench web styleguide",
2316
+ method: "POST",
2317
+ path: "/context/brand-styleguide",
2318
+ requestSchema: anyObject,
2319
+ responseSchema: anyResponse,
2320
+ backend: gateway("POST", "/context/brand-styleguide"),
2321
+ }),
2322
+ op({
2323
+ id: "web.fonts",
2324
+ group: "gateway",
2325
+ summary: "Read site fonts from a domain.",
2326
+ cli: "dench web fonts",
2327
+ method: "POST",
2328
+ path: "/context/brand-fonts",
2329
+ requestSchema: anyObject,
2330
+ responseSchema: anyResponse,
2331
+ backend: gateway("POST", "/context/brand-fonts"),
2332
+ }),
2333
+ op({
2334
+ id: "web.classify",
2335
+ group: "gateway",
2336
+ summary: "Classify a domain with NAICS or SIC.",
2337
+ cli: "dench web classify",
2338
+ method: "POST",
2339
+ path: "/context/classify-naics",
2340
+ requestSchema: anyObject,
2341
+ responseSchema: anyResponse,
2342
+ backend: gateway("POST", "/context/classify-naics"),
2343
+ }),
2344
+ op({
2345
+ id: "web.brandSearch",
2346
+ group: "gateway",
2347
+ summary: "Autocomplete brand names.",
2348
+ cli: "dench web brand-search",
2349
+ method: "POST",
2350
+ path: "/context/brand-search",
2351
+ requestSchema: anyObject,
2352
+ responseSchema: anyResponse,
2353
+ backend: gateway("POST", "/context/brand-search"),
2354
+ }),
2223
2355
  op({
2224
2356
  id: "image.generate",
2225
2357
  group: "gateway",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.7.8",
3
+ "version": "2.7.9-staging.86",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "browser.ts",
31
31
  "ads.ts",
32
32
  "search.ts",
33
+ "web.ts",
33
34
  "image.ts",
34
35
  "tools.ts",
35
36
  "apps.ts",
package/web.ts ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * `dench web` — brand, scrape, sitemap, crawl, screenshot, and extract
3
+ * through the Dench Cloud Gateway (`POST /v1/context/{op}`).
4
+ *
5
+ * Agents and humans use this instead of writing their own scrapers.
6
+ * Auth matches `dench search`: org DENCH_API_KEY / paid-workspace gateway key.
7
+ */
8
+
9
+ import { assertNoUnknownFlags, getFlag, hasFlag } from "./lib/cli-args";
10
+
11
+ type WebCliContext = {
12
+ args: string[];
13
+ jsonOutput: boolean;
14
+ bearerToken?: string;
15
+ gatewayBaseUrl?: string;
16
+ };
17
+
18
+ class WebCliError extends Error {}
19
+
20
+ const CRAWL_DEFAULT_PAGES = 15;
21
+ const CRAWL_MAX_PAGES = 50;
22
+
23
+ function parsePositiveInt(name: string, raw: string | undefined): number | undefined {
24
+ if (raw === undefined) return undefined;
25
+ const parsed = Number(raw);
26
+ if (!Number.isFinite(parsed) || parsed <= 0) {
27
+ throw new WebCliError(`Invalid ${name} value: ${raw}`);
28
+ }
29
+ return Math.floor(parsed);
30
+ }
31
+
32
+ function normalizeGatewayBaseUrl(value: string): string {
33
+ return value.replace(/\/+$/, "").replace(/\/v1$/, "");
34
+ }
35
+
36
+ function resolveGatewayBaseUrl(
37
+ ctx?: Pick<WebCliContext, "gatewayBaseUrl">,
38
+ ): string {
39
+ if (ctx?.gatewayBaseUrl?.trim()) {
40
+ return normalizeGatewayBaseUrl(ctx.gatewayBaseUrl.trim());
41
+ }
42
+ const explicit =
43
+ process.env.DENCH_GATEWAY_URL?.trim() || process.env.GATEWAY_URL?.trim();
44
+ if (explicit) return normalizeGatewayBaseUrl(explicit);
45
+ const host = process.env.DENCH_HOST?.trim();
46
+ if (host && /localhost|127\.0\.0\.1/.test(host)) {
47
+ return "http://localhost:8787";
48
+ }
49
+ return "https://gateway.merseoriginals.com";
50
+ }
51
+
52
+ function requireBearerToken(ctx?: Pick<WebCliContext, "bearerToken">): string {
53
+ const bearerToken =
54
+ ctx?.bearerToken?.trim() || process.env.DENCH_API_KEY?.trim();
55
+ if (!bearerToken) {
56
+ throw new WebCliError(
57
+ "No Dench gateway credential found. Set DENCH_API_KEY or run `dench signin` for a paid workspace.",
58
+ );
59
+ }
60
+ return bearerToken;
61
+ }
62
+
63
+ async function callWebOp(
64
+ ctx: WebCliContext,
65
+ op: string,
66
+ input: Record<string, unknown>,
67
+ ): Promise<unknown> {
68
+ const bearerToken = requireBearerToken(ctx);
69
+ const baseUrl = resolveGatewayBaseUrl(ctx);
70
+ const response = await fetch(`${baseUrl}/v1/context/${op}`, {
71
+ method: "POST",
72
+ headers: {
73
+ "content-type": "application/json",
74
+ Authorization: `Bearer ${bearerToken}`,
75
+ },
76
+ body: JSON.stringify({ input }),
77
+ });
78
+ if (!response.ok) {
79
+ let detail = "";
80
+ try {
81
+ detail = await response.text();
82
+ } catch {
83
+ // ignore
84
+ }
85
+ const truncated = detail.length > 500 ? `${detail.slice(0, 500)}…` : detail;
86
+ throw new WebCliError(
87
+ `Gateway /v1/context/${op} returned ${response.status}: ${truncated || response.statusText}`,
88
+ );
89
+ }
90
+ return response.json();
91
+ }
92
+
93
+ function asString(value: unknown): string | undefined {
94
+ return typeof value === "string" ? value : undefined;
95
+ }
96
+
97
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
98
+ return value && typeof value === "object" && !Array.isArray(value)
99
+ ? (value as Record<string, unknown>)
100
+ : undefined;
101
+ }
102
+
103
+ function out(ctx: WebCliContext, payload: unknown, formatted: string): void {
104
+ if (ctx.jsonOutput) {
105
+ console.log(JSON.stringify(payload, null, 2));
106
+ return;
107
+ }
108
+ console.log(formatted);
109
+ }
110
+
111
+ function formatBrand(data: unknown): string {
112
+ const record = asRecord(data);
113
+ if (!record) return "(empty brand)";
114
+ const lines = [
115
+ asString(record.title) ?? asString(record.name) ?? "(untitled brand)",
116
+ asString(record.domain) ? ` ${asString(record.domain)}` : "",
117
+ asString(record.description) ? ` ${asString(record.description)}` : "",
118
+ ].filter((line) => line.length > 0);
119
+ return lines.join("\n");
120
+ }
121
+
122
+ function formatMarkdown(data: unknown): string {
123
+ const record = asRecord(data);
124
+ return asString(record?.markdown) ?? JSON.stringify(data, null, 2);
125
+ }
126
+
127
+ export function webHelp(): void {
128
+ console.log(`Dench web
129
+
130
+ Read pages, brand kits, sitemaps, screenshots, and structured extracts
131
+ through the Dench gateway. Prefer these over writing your own scraper.
132
+
133
+ dench web scrape <url> [--json]
134
+ dench web brand <domain|email> [--json]
135
+ dench web sitemap <domain> [--limit N] [--json]
136
+ dench web crawl <url> [--max-pages N] [--json]
137
+ dench web screenshot <url> [--full-page] [--json]
138
+ dench web extract <url> --schema '<json>' [--json]
139
+ dench web product <url> [--json]
140
+ dench web competitors <domain> [--json]
141
+ dench web styleguide <domain> [--json]
142
+ dench web fonts <domain> [--json]
143
+ dench web classify <domain> [--scheme naics|sic] [--json]
144
+ dench web brand-search <query> [--json]
145
+
146
+ Crawl defaults to 15 pages and is hard-capped at 50. Search stays on
147
+ \`dench search\` (Exa). \`dench context\` is workspace orientation, not this.
148
+ `);
149
+ }
150
+
151
+ async function runWebSubcommand(ctx: WebCliContext): Promise<void> {
152
+ const sub = ctx.args[0];
153
+ const rest = ctx.args.slice(1);
154
+
155
+ if (!sub || sub === "help" || sub === "--help" || sub === "-h") {
156
+ webHelp();
157
+ return;
158
+ }
159
+
160
+ if (sub === "scrape") {
161
+ assertNoUnknownFlags(rest, "web scrape");
162
+ const url = rest[0];
163
+ if (!url) throw new WebCliError("Usage: dench web scrape <url> [--json]");
164
+ const data = await callWebOp(ctx, "web-scrape-markdown", {
165
+ url,
166
+ useMainContentOnly: true,
167
+ });
168
+ out(ctx, data, formatMarkdown(data));
169
+ return;
170
+ }
171
+
172
+ if (sub === "brand") {
173
+ assertNoUnknownFlags(rest, "web brand");
174
+ const identifier = rest[0];
175
+ if (!identifier) {
176
+ throw new WebCliError("Usage: dench web brand <domain|email> [--json]");
177
+ }
178
+ const input = identifier.includes("@")
179
+ ? { email: identifier }
180
+ : { domain: identifier };
181
+ const data = await callWebOp(ctx, "brand-retrieve", input);
182
+ out(ctx, data, formatBrand(data));
183
+ return;
184
+ }
185
+
186
+ if (sub === "brand-search") {
187
+ assertNoUnknownFlags(rest, "web brand-search");
188
+ const query = rest.join(" ").trim();
189
+ if (!query) {
190
+ throw new WebCliError("Usage: dench web brand-search <query> [--json]");
191
+ }
192
+ const data = await callWebOp(ctx, "brand-search", { query });
193
+ out(ctx, data, JSON.stringify(data, null, 2));
194
+ return;
195
+ }
196
+
197
+ if (sub === "sitemap") {
198
+ const limit = parsePositiveInt("--limit", getFlag(rest, "--limit"));
199
+ assertNoUnknownFlags(rest, "web sitemap");
200
+ const domain = rest[0];
201
+ if (!domain) {
202
+ throw new WebCliError("Usage: dench web sitemap <domain> [--limit N] [--json]");
203
+ }
204
+ const data = await callWebOp(ctx, "web-sitemap", {
205
+ domain,
206
+ ...(limit != null ? { maxLinks: limit } : {}),
207
+ });
208
+ out(ctx, data, JSON.stringify(data, null, 2));
209
+ return;
210
+ }
211
+
212
+ if (sub === "crawl") {
213
+ const maxPages =
214
+ parsePositiveInt("--max-pages", getFlag(rest, "--max-pages")) ??
215
+ CRAWL_DEFAULT_PAGES;
216
+ if (maxPages > CRAWL_MAX_PAGES) {
217
+ throw new WebCliError(`--max-pages cannot exceed ${CRAWL_MAX_PAGES}.`);
218
+ }
219
+ assertNoUnknownFlags(rest, "web crawl");
220
+ const url = rest[0];
221
+ if (!url) {
222
+ throw new WebCliError(
223
+ "Usage: dench web crawl <url> [--max-pages N] [--json]",
224
+ );
225
+ }
226
+ const data = await callWebOp(ctx, "web-crawl", {
227
+ url,
228
+ maxPages,
229
+ useMainContentOnly: true,
230
+ });
231
+ out(ctx, data, JSON.stringify(data, null, 2));
232
+ return;
233
+ }
234
+
235
+ if (sub === "screenshot") {
236
+ const fullPage = hasFlag(rest, "--full-page");
237
+ assertNoUnknownFlags(rest, "web screenshot");
238
+ const url = rest[0];
239
+ if (!url) {
240
+ throw new WebCliError(
241
+ "Usage: dench web screenshot <url> [--full-page] [--json]",
242
+ );
243
+ }
244
+ const data = await callWebOp(ctx, "web-screenshot", {
245
+ directUrl: url,
246
+ fullScreenshot: fullPage,
247
+ });
248
+ const record = asRecord(data);
249
+ out(ctx, data, asString(record?.screenshot) ?? JSON.stringify(data, null, 2));
250
+ return;
251
+ }
252
+
253
+ if (sub === "extract") {
254
+ const schemaRaw = getFlag(rest, "--schema");
255
+ assertNoUnknownFlags(rest, "web extract");
256
+ const url = rest[0];
257
+ if (!url || !schemaRaw) {
258
+ throw new WebCliError(
259
+ "Usage: dench web extract <url> --schema '<json>' [--json]",
260
+ );
261
+ }
262
+ let schema: unknown;
263
+ try {
264
+ schema = JSON.parse(schemaRaw);
265
+ } catch {
266
+ throw new WebCliError("--schema must be valid JSON.");
267
+ }
268
+ if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
269
+ throw new WebCliError("--schema must be a JSON object.");
270
+ }
271
+ const data = await callWebOp(ctx, "web-extract", { url, schema });
272
+ out(ctx, data, JSON.stringify(data, null, 2));
273
+ return;
274
+ }
275
+
276
+ if (sub === "product") {
277
+ assertNoUnknownFlags(rest, "web product");
278
+ const url = rest[0];
279
+ if (!url) throw new WebCliError("Usage: dench web product <url> [--json]");
280
+ const data = await callWebOp(ctx, "web-product", { url });
281
+ out(ctx, data, JSON.stringify(data, null, 2));
282
+ return;
283
+ }
284
+
285
+ if (sub === "competitors") {
286
+ assertNoUnknownFlags(rest, "web competitors");
287
+ const domain = rest[0];
288
+ if (!domain) {
289
+ throw new WebCliError("Usage: dench web competitors <domain> [--json]");
290
+ }
291
+ const data = await callWebOp(ctx, "web-competitors", {
292
+ domain,
293
+ numCompetitors: 5,
294
+ });
295
+ out(ctx, data, JSON.stringify(data, null, 2));
296
+ return;
297
+ }
298
+
299
+ if (sub === "styleguide") {
300
+ assertNoUnknownFlags(rest, "web styleguide");
301
+ const domain = rest[0];
302
+ if (!domain) {
303
+ throw new WebCliError("Usage: dench web styleguide <domain> [--json]");
304
+ }
305
+ const data = await callWebOp(ctx, "brand-styleguide", { domain });
306
+ out(ctx, data, JSON.stringify(data, null, 2));
307
+ return;
308
+ }
309
+
310
+ if (sub === "fonts") {
311
+ assertNoUnknownFlags(rest, "web fonts");
312
+ const domain = rest[0];
313
+ if (!domain) throw new WebCliError("Usage: dench web fonts <domain> [--json]");
314
+ const data = await callWebOp(ctx, "brand-fonts", { domain });
315
+ out(ctx, data, JSON.stringify(data, null, 2));
316
+ return;
317
+ }
318
+
319
+ if (sub === "classify") {
320
+ const scheme = getFlag(rest, "--scheme") ?? "naics";
321
+ if (scheme !== "naics" && scheme !== "sic") {
322
+ throw new WebCliError("Invalid --scheme. Allowed: naics, sic");
323
+ }
324
+ assertNoUnknownFlags(rest, "web classify");
325
+ const domain = rest[0];
326
+ if (!domain) {
327
+ throw new WebCliError(
328
+ "Usage: dench web classify <domain> [--scheme naics|sic] [--json]",
329
+ );
330
+ }
331
+ const data = await callWebOp(
332
+ ctx,
333
+ scheme === "sic" ? "classify-sic" : "classify-naics",
334
+ { input: domain },
335
+ );
336
+ out(ctx, data, JSON.stringify(data, null, 2));
337
+ return;
338
+ }
339
+
340
+ throw new WebCliError(
341
+ `Unknown dench web command "${sub}". Run \`dench web --help\`.`,
342
+ );
343
+ }
344
+
345
+ export async function runWebCommand(ctx: WebCliContext): Promise<void> {
346
+ try {
347
+ await runWebSubcommand(ctx);
348
+ } catch (error) {
349
+ if (error instanceof WebCliError) {
350
+ console.error(error.message);
351
+ process.exitCode = 1;
352
+ return;
353
+ }
354
+ throw error;
355
+ }
356
+ }