unbrowse 2.10.2 → 2.12.0

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.
@@ -8,10 +8,18 @@
8
8
  */
9
9
 
10
10
  import { config as loadEnv } from "dotenv";
11
- import { ensureRegistered, getApiKey } from "./client/index.js";
11
+ import { spawn } from "node:child_process";
12
+ import {
13
+ detectTelemetryHostType,
14
+ ensureCliInstallTracked,
15
+ ensureRegistered,
16
+ getApiKey,
17
+ recordFunnelTelemetryEvent,
18
+ recordInstallTelemetryEvent,
19
+ } from "./client/index.js";
12
20
  import { findSitePack, findTask, allSitePacks, buildDepsGraph, planExecution, buildDepsMetadata, type SitePack } from "./cli/shortcuts.js";
13
21
  import { ensureLocalServer, checkServerVersion, stopServer, restartServer } from "./runtime/local-server.js";
14
- import { isMainModule } from "./runtime/paths.js";
22
+ import { isMainModule, resolveSiblingEntrypoint, runtimeArgsForEntrypoint } from "./runtime/paths.js";
15
23
  import { drainPendingIndexJobs } from "./indexer/index.js";
16
24
  import { drainPendingPassivePublishes } from "./orchestrator/passive-publish.js";
17
25
  import { runSetup, type SetupReport, type SetupScope } from "./runtime/setup.js";
@@ -85,6 +93,28 @@ function info(msg: string): void {
85
93
  process.stderr.write(`[unbrowse] ${msg}\n`);
86
94
  }
87
95
 
96
+ function resolveResultError(result: Record<string, unknown>): string | undefined {
97
+ return (result.result as Record<string, unknown> | undefined)?.error as string | undefined
98
+ ?? result.error as string | undefined;
99
+ }
100
+
101
+ function resolveLoginUrl(result: Record<string, unknown>, fallbackUrl?: string): string {
102
+ return (result.result as Record<string, unknown> | undefined)?.login_url as string
103
+ ?? fallbackUrl
104
+ ?? "";
105
+ }
106
+
107
+ function hasIndexingFallback(result: Record<string, unknown>): boolean {
108
+ return (result.result as Record<string, unknown> | undefined)?.indexing_fallback_available === true;
109
+ }
110
+
111
+ function isResolveSuccessResult(result: Record<string, unknown>): boolean {
112
+ const resultObj = result.result as Record<string, unknown> | undefined;
113
+ if (resolveResultError(result)) return false;
114
+ if (resultObj?.status === "browse_session_open") return false;
115
+ return !!result.result || Array.isArray(result.available_endpoints);
116
+ }
117
+
88
118
  async function withPendingNotice<T>(promise: Promise<T>, message: string, delayMs = 3_000): Promise<T> {
89
119
  let done = false;
90
120
  const timer = setTimeout(() => {
@@ -126,130 +156,279 @@ function slimTrace(obj: Record<string, unknown>): Record<string, unknown> {
126
156
  };
127
157
  if ("result" in obj) out.result = obj.result;
128
158
  if (obj.available_endpoints) out.available_endpoints = obj.available_endpoints;
159
+ if (obj.impact) out.impact = obj.impact;
160
+ if (obj.next_actions) out.next_actions = obj.next_actions;
161
+ if (obj.next_step) out.next_step = obj.next_step;
129
162
  if (obj.source) out.source = obj.source;
130
163
  if (obj.skill) out.skill = obj.skill;
131
164
  return out;
132
165
  }
133
166
 
167
+ function formatSavedDuration(ms: number): string {
168
+ if (ms >= 60_000) return `${(ms / 60_000).toFixed(1)}m`;
169
+ if (ms >= 10_000) return `${Math.round(ms / 1000)}s`;
170
+ if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
171
+ return `${ms}ms`;
172
+ }
173
+
174
+ function emitImpactSummary(result: Record<string, unknown>): void {
175
+ const impact = result.impact as Record<string, unknown> | undefined;
176
+ if (!impact) return;
177
+
178
+ const timeSavedMs = typeof impact.time_saved_ms === "number" ? impact.time_saved_ms : 0;
179
+ const tokensSaved = typeof impact.tokens_saved === "number" ? impact.tokens_saved : 0;
180
+ const timeSavedPct = typeof impact.time_saved_pct === "number" ? impact.time_saved_pct : 0;
181
+ const tokensSavedPct = typeof impact.tokens_saved_pct === "number" ? impact.tokens_saved_pct : 0;
182
+ const browserAvoided = impact.browser_avoided === true;
183
+ if (timeSavedMs <= 0 && tokensSaved <= 0 && !browserAvoided) return;
184
+
185
+ const parts: string[] = [];
186
+ if (timeSavedMs > 0) parts.push(`${formatSavedDuration(timeSavedMs)} saved (${timeSavedPct}% faster)`);
187
+ if (tokensSaved > 0) parts.push(`${tokensSaved.toLocaleString("en-US")} tokens saved (${tokensSavedPct}% less context)`);
188
+ if (browserAvoided) parts.push("browser avoided");
189
+ info(parts.join(" • "));
190
+ }
191
+
192
+ function emitNextActionSummary(result: Record<string, unknown>): void {
193
+ const nextActions = Array.isArray(result.next_actions)
194
+ ? result.next_actions as Array<Record<string, unknown>>
195
+ : [];
196
+ if (nextActions.length === 0) return;
197
+ info("Likely next actions:");
198
+ for (const action of nextActions.slice(0, 3)) {
199
+ const command = typeof action.command === "string" ? action.command : "";
200
+ const title = typeof action.title === "string" ? action.title : (action.endpoint_id as string | undefined) ?? "next step";
201
+ const why = typeof action.why === "string" ? action.why : "";
202
+ info(` ${command || title}${why ? ` # ${why}` : ""}`);
203
+ }
204
+ }
205
+
134
206
 
135
207
  async function cmdHealth(flags: Record<string, string | boolean>): Promise<void> {
136
208
  output(await api("GET", "/health"), !!flags.pretty);
137
209
  }
138
210
 
211
+ function telemetryDomainFromInput(domain?: string, url?: string): string | null {
212
+ if (domain?.trim()) return domain.trim().replace(/^www\./, "");
213
+ if (!url?.trim()) return null;
214
+ try {
215
+ return new URL(url).hostname.replace(/^www\./, "");
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
139
221
  async function cmdResolve(flags: Record<string, string | boolean>): Promise<void> {
140
222
  const intent = flags.intent as string;
141
223
  if (!intent) die("--intent is required");
224
+ const hostType = detectTelemetryHostType();
225
+ await ensureCliInstallTracked(hostType);
226
+ await recordFunnelTelemetryEvent("cli_invoked", {
227
+ source: "cli",
228
+ hostType,
229
+ properties: { command: "resolve" },
230
+ });
231
+ await recordFunnelTelemetryEvent("resolve_started", {
232
+ source: "cli",
233
+ hostType,
234
+ properties: {
235
+ command: "resolve",
236
+ intent,
237
+ domain: telemetryDomainFromInput(flags.domain as string | undefined, flags.url as string | undefined),
238
+ url: typeof flags.url === "string" ? flags.url : null,
239
+ has_url: typeof flags.url === "string",
240
+ has_domain: typeof flags.domain === "string",
241
+ auto_execute: !!flags.execute,
242
+ },
243
+ });
142
244
 
143
- const body: Record<string, unknown> = { intent };
144
- const url = flags.url as string | undefined;
145
- const domain = flags.domain as string | undefined;
146
- const explicitEndpointId = flags["endpoint-id"] as string | undefined;
147
- const autoExecute = !!flags.execute;
148
- const extraParams = flags.params ? JSON.parse(flags.params as string) : {};
245
+ try {
246
+ const body: Record<string, unknown> = { intent };
247
+ const url = flags.url as string | undefined;
248
+ const domain = flags.domain as string | undefined;
249
+ const explicitEndpointId = flags["endpoint-id"] as string | undefined;
250
+ const autoExecute = !!flags.execute;
251
+ const extraParams = flags.params ? JSON.parse(flags.params as string) : {};
252
+
253
+ if (url) {
254
+ body.params = { url };
255
+ body.context = { url };
256
+ }
257
+ if (domain) {
258
+ body.context = { ...(body.context as Record<string, unknown> ?? {}), domain };
259
+ }
260
+ if (explicitEndpointId) {
261
+ body.params = { ...(body.params as Record<string, unknown> ?? {}), endpoint_id: explicitEndpointId };
262
+ }
263
+ if (flags.params) {
264
+ body.params = { ...(body.params as Record<string, unknown> ?? {}), ...extraParams };
265
+ }
266
+ if (flags["dry-run"]) body.dry_run = true;
267
+ if (flags["force-capture"]) body.force_capture = true;
268
+ body.projection = { raw: true };
149
269
 
150
- if (url) {
151
- body.params = { url };
152
- body.context = { url };
153
- }
154
- if (domain) {
155
- body.context = { ...(body.context as Record<string, unknown> ?? {}), domain };
156
- }
157
- if (explicitEndpointId) {
158
- body.params = { ...(body.params as Record<string, unknown> ?? {}), endpoint_id: explicitEndpointId };
159
- }
160
- if (flags.params) {
161
- body.params = { ...(body.params as Record<string, unknown> ?? {}), ...extraParams };
162
- }
163
- if (flags["dry-run"]) body.dry_run = true;
164
- if (flags["force-capture"]) body.force_capture = true;
165
- body.projection = { raw: true };
270
+ function execBody(endpointId: string): Record<string, unknown> {
271
+ return { params: { endpoint_id: endpointId, ...extraParams }, intent, projection: { raw: true } };
272
+ }
166
273
 
167
- function execBody(endpointId: string): Record<string, unknown> {
168
- return { params: { endpoint_id: endpointId, ...extraParams }, intent, projection: { raw: true } };
169
- }
274
+ function resolveSkillId(): string | undefined {
275
+ return (result.skill as Record<string, unknown>)?.skill_id as string
276
+ ?? (result as Record<string, unknown>).skill_id as string;
277
+ }
170
278
 
171
- function resolveSkillId(): string | undefined {
172
- return (result.skill as Record<string, unknown>)?.skill_id as string
173
- ?? (result as Record<string, unknown>).skill_id as string;
174
- }
279
+ const startedAt = Date.now();
280
+ async function resolveOnce(message = "Still working. First-time capture/indexing for a site can take 20-80s. Waiting is usually better than falling back."): Promise<Record<string, unknown>> {
281
+ return withPendingNotice(
282
+ api("POST", "/v1/intent/resolve", body) as Promise<Record<string, unknown>>,
283
+ message,
284
+ );
285
+ }
175
286
 
176
- const startedAt = Date.now();
177
- let result = await withPendingNotice(
178
- api("POST", "/v1/intent/resolve", body) as Promise<Record<string, unknown>>,
179
- "Still working. First-time capture/indexing for a site can take 20-80s. Waiting is usually better than falling back.",
180
- );
287
+ let result = await resolveOnce();
288
+ let attemptedForceCapture = !!body.force_capture;
289
+ let attemptedCookieImport = false;
290
+ let attemptedInteractiveLogin = false;
291
+
292
+ while (true) {
293
+ const resultError = resolveResultError(result);
294
+ if (resultError === "payment_required" && hasIndexingFallback(result) && url && !attemptedForceCapture) {
295
+ attemptedForceCapture = true;
296
+ body.force_capture = true;
297
+ info("Marketplace search is paid here. Falling back to free live capture on the exact URL...");
298
+ result = await resolveOnce("Running free live capture...");
299
+ continue;
300
+ }
301
+
302
+ if (resultError === "auth_required") {
303
+ const loginUrl = resolveLoginUrl(result, url);
304
+ if (!loginUrl) break;
305
+
306
+ if (!attemptedCookieImport) {
307
+ attemptedCookieImport = true;
308
+ info("Site requires authentication. Trying browser cookie import first...");
309
+ const stealResult = await api("POST", "/v1/auth/steal", { url: loginUrl }) as Record<string, unknown>;
310
+ const cookiesStored = typeof stealResult.cookies_stored === "number"
311
+ ? stealResult.cookies_stored
312
+ : Number(stealResult.cookies_stored ?? 0);
313
+ if (stealResult.success === true && cookiesStored > 0) {
314
+ info(`Imported ${cookiesStored} browser cookies. Retrying...`);
315
+ result = await resolveOnce("Retrying after browser cookie import...");
316
+ continue;
317
+ }
318
+ }
319
+
320
+ if (!attemptedInteractiveLogin) {
321
+ attemptedInteractiveLogin = true;
322
+ info("Site requires authentication. Opening browser for login...");
323
+ const loginResult = await api("POST", "/v1/auth/login", { url: loginUrl }) as Record<string, unknown>;
324
+ if (loginResult.error || loginResult.success === false) {
325
+ const message = typeof loginResult.error === "string"
326
+ ? loginResult.error
327
+ : "interactive login did not produce a reusable session";
328
+ throw new Error(`Login failed: ${message}. Run: unbrowse login --url "${loginUrl}"`);
329
+ }
330
+ info("Login complete. Retrying...");
331
+ result = await resolveOnce("Retrying after login...");
332
+ continue;
333
+ }
334
+ }
335
+
336
+ break;
337
+ }
181
338
 
182
- // Auto-handle auth: if site requires login, trigger interactive login and retry
183
- const resultError = (result.result as Record<string, unknown>)?.error
184
- ?? (result as Record<string, unknown>).error;
185
- if (resultError === "auth_required") {
186
- const loginUrl = (result.result as Record<string, unknown>)?.login_url as string
187
- ?? url ?? "";
188
- if (loginUrl) {
189
- info("Site requires authentication. Opening browser for login...");
190
- try {
191
- await api("POST", "/v1/auth/login", { url: loginUrl });
192
- info("Login complete. Retrying...");
339
+ // When agent explicitly picked an endpoint but resolve deferred, execute it directly
340
+ if (explicitEndpointId && result.available_endpoints) {
341
+ const skillId = resolveSkillId();
342
+ if (skillId) {
193
343
  result = await withPendingNotice(
194
- api("POST", "/v1/intent/resolve", body) as Promise<Record<string, unknown>>,
195
- "Retrying after login...",
344
+ api("POST", `/v1/skills/${skillId}/execute`, execBody(explicitEndpointId)) as Promise<Record<string, unknown>>,
345
+ "Executing selected endpoint...",
196
346
  );
197
- } catch (err) {
198
- die(`Login failed: ${(err as Error).message}. Run: unbrowse login --url "${loginUrl}"`);
199
347
  }
200
348
  }
201
- }
202
349
 
203
- // When agent explicitly picked an endpoint but resolve deferred, execute it directly
204
- if (explicitEndpointId && result.available_endpoints) {
205
- const skillId = resolveSkillId();
206
- if (skillId) {
207
- result = await withPendingNotice(
208
- api("POST", `/v1/skills/${skillId}/execute`, execBody(explicitEndpointId)) as Promise<Record<string, unknown>>,
209
- "Executing selected endpoint...",
210
- );
350
+ // --execute: auto-pick best endpoint and return data in one step
351
+ if (autoExecute && result.available_endpoints && !result.result) {
352
+ const endpoints = result.available_endpoints as Array<Record<string, unknown>>;
353
+ const skillId = resolveSkillId();
354
+ if (skillId && endpoints.length > 0) {
355
+ const bestEndpoint = endpoints[0];
356
+ info(`Auto-executing endpoint: ${bestEndpoint.description ?? bestEndpoint.endpoint_id}`);
357
+ result = await withPendingNotice(
358
+ api("POST", `/v1/skills/${skillId}/execute`, execBody(bestEndpoint.endpoint_id as string)) as Promise<Record<string, unknown>>,
359
+ "Executing best endpoint...",
360
+ );
361
+ }
211
362
  }
212
- }
213
363
 
214
- // --execute: auto-pick best endpoint and return data in one step
215
- if (autoExecute && result.available_endpoints && !result.result) {
216
- const endpoints = result.available_endpoints as Array<Record<string, unknown>>;
217
- const skillId = resolveSkillId();
218
- if (skillId && endpoints.length > 0) {
219
- const bestEndpoint = endpoints[0];
220
- info(`Auto-executing endpoint: ${bestEndpoint.description ?? bestEndpoint.endpoint_id}`);
221
- result = await withPendingNotice(
222
- api("POST", `/v1/skills/${skillId}/execute`, execBody(bestEndpoint.endpoint_id as string)) as Promise<Record<string, unknown>>,
223
- "Executing best endpoint...",
224
- );
364
+ // Browse session handoff
365
+ const resultObj = result.result as Record<string, unknown> | undefined;
366
+ if (resultObj?.status === "browse_session_open") {
367
+ info(`No cached API. Browser session open on ${resultObj.domain ?? resultObj.url}.`);
368
+ info(`Preferred flow: snap -> click/fill/eval -> submit -> sync -> close.`);
369
+ info(`Use these commands to get your data:`);
370
+ const commands = resultObj.commands as string[] ?? [
371
+ "unbrowse snap --filter interactive",
372
+ "unbrowse click <ref>",
373
+ "unbrowse fill <ref> <value>",
374
+ "unbrowse submit --wait-for \"/next-step\"",
375
+ "unbrowse sync",
376
+ "unbrowse close",
377
+ ];
378
+ for (const cmd of commands) info(` ${cmd}`);
379
+ info(`For JS-heavy forms: prefer real date/time clicks first, inspect hidden inputs with eval when needed, then submit.`);
380
+ info(`All traffic is being passively captured. Run "unbrowse close" when done.`);
381
+ output(slimTrace(result), !!flags.pretty);
382
+ return;
225
383
  }
226
- }
227
384
 
228
- // Browse session handoff
229
- const resultObj = result.result as Record<string, unknown> | undefined;
230
- if (resultObj?.status === "browse_session_open") {
231
- info(`No cached API. Browser session open on ${resultObj.domain ?? resultObj.url}.`);
232
- info(`Use these commands to get your data:`);
233
- const commands = resultObj.commands as string[] ?? ["unbrowse snap --filter interactive", "unbrowse click <ref>", "unbrowse close"];
234
- for (const cmd of commands) info(` ${cmd}`);
235
- info(`All traffic is being passively captured. Run "unbrowse close" when done.`);
236
- output(slimTrace(result), !!flags.pretty);
237
- return;
238
- }
385
+ if (Date.now() - startedAt > 3_000 && result.source === "live-capture") {
386
+ info("Live capture finished. Future runs against this site should be much faster.");
387
+ }
239
388
 
240
- if (Date.now() - startedAt > 3_000 && result.source === "live-capture") {
241
- info("Live capture finished. Future runs against this site should be much faster.");
242
- }
389
+ if (isResolveSuccessResult(result)) {
390
+ await recordFunnelTelemetryEvent("resolve_completed", {
391
+ source: "cli",
392
+ hostType,
393
+ properties: {
394
+ command: "resolve",
395
+ intent,
396
+ domain: telemetryDomainFromInput(domain, url),
397
+ url: url ?? null,
398
+ source: result.source,
399
+ auto_execute: autoExecute,
400
+ explicit_endpoint: explicitEndpointId ?? null,
401
+ },
402
+ });
403
+ }
243
404
 
244
- result = slimTrace(result);
405
+ result = slimTrace(result);
406
+ emitImpactSummary(result);
407
+ emitNextActionSummary(result);
245
408
 
246
- const skill = result.skill as Record<string, unknown> | undefined;
247
- const trace = result.trace as Record<string, unknown> | undefined;
248
- if (skill?.skill_id && trace) {
249
- (result as Record<string, unknown>)._feedback = `unbrowse feedback --skill ${skill.skill_id} --endpoint ${trace.endpoint_id || "?"} --rating <1-5>`;
250
- }
409
+ const skill = result.skill as Record<string, unknown> | undefined;
410
+ const trace = result.trace as Record<string, unknown> | undefined;
411
+ if (skill?.skill_id && trace) {
412
+ (result as Record<string, unknown>)._feedback = `unbrowse feedback --skill ${skill.skill_id} --endpoint ${trace.endpoint_id || "?"} --rating <1-5>`;
413
+ }
251
414
 
252
- output(result, !!flags.pretty);
415
+ output(result, !!flags.pretty);
416
+ } catch (error) {
417
+ const message = error instanceof Error ? error.message : String(error);
418
+ await recordFunnelTelemetryEvent("resolve_failed", {
419
+ source: "cli",
420
+ hostType,
421
+ properties: {
422
+ command: "resolve",
423
+ intent,
424
+ domain: telemetryDomainFromInput(flags.domain as string | undefined, flags.url as string | undefined),
425
+ url: typeof flags.url === "string" ? flags.url : null,
426
+ failure_stage: "resolve",
427
+ failure_reason: message,
428
+ },
429
+ });
430
+ throw error;
431
+ }
253
432
  }
254
433
 
255
434
  // ---------------------------------------------------------------------------
@@ -342,87 +521,154 @@ function schemaOf(value: unknown, depth = 4): unknown {
342
521
  async function cmdExecute(flags: Record<string, string | boolean>): Promise<void> {
343
522
  const skillId = flags.skill as string;
344
523
  if (!skillId) die("--skill is required");
524
+ const hostType = detectTelemetryHostType();
525
+ await ensureCliInstallTracked(hostType);
526
+ await recordFunnelTelemetryEvent("cli_invoked", {
527
+ source: "cli",
528
+ hostType,
529
+ properties: { command: "execute" },
530
+ });
531
+ await recordFunnelTelemetryEvent("resolve_started", {
532
+ source: "cli",
533
+ hostType,
534
+ properties: {
535
+ command: "execute",
536
+ intent: typeof flags.intent === "string" ? flags.intent : null,
537
+ domain: telemetryDomainFromInput(undefined, flags.url as string | undefined),
538
+ url: typeof flags.url === "string" ? flags.url : null,
539
+ skill_id: skillId,
540
+ endpoint_id: typeof flags.endpoint === "string" ? flags.endpoint : null,
541
+ },
542
+ });
345
543
 
346
- const body: Record<string, unknown> = { params: {} };
347
- if (flags.endpoint) {
348
- (body.params as Record<string, unknown>).endpoint_id = flags.endpoint;
349
- }
350
- if (flags.params) {
351
- body.params = { ...(body.params as Record<string, unknown>), ...JSON.parse(flags.params as string) };
352
- }
353
- if (flags.url) {
354
- body.context_url = flags.url;
355
- (body.params as Record<string, unknown>).url = flags.url;
356
- }
357
- if (flags.intent) body.intent = flags.intent;
358
- if (flags["dry-run"]) body.dry_run = true;
359
- if (flags["confirm-unsafe"]) body.confirm_unsafe = true;
360
- body.projection = { raw: true };
544
+ try {
545
+ const body: Record<string, unknown> = { params: {} };
546
+ if (flags.endpoint) {
547
+ (body.params as Record<string, unknown>).endpoint_id = flags.endpoint;
548
+ }
549
+ if (flags.params) {
550
+ body.params = { ...(body.params as Record<string, unknown>), ...JSON.parse(flags.params as string) };
551
+ }
552
+ if (flags.url) {
553
+ body.context_url = flags.url;
554
+ (body.params as Record<string, unknown>).url = flags.url;
555
+ }
556
+ if (flags.intent) body.intent = flags.intent;
557
+ if (flags["dry-run"]) body.dry_run = true;
558
+ if (flags["confirm-unsafe"]) body.confirm_unsafe = true;
559
+ body.projection = { raw: true };
560
+
561
+ let result = await withPendingNotice(
562
+ api("POST", `/v1/skills/${skillId}/execute`, body) as Promise<Record<string, unknown>>,
563
+ "Still working. This endpoint may require browser replay or first-time auth/capture setup.",
564
+ );
565
+
566
+ if (isResolveSuccessResult(result)) {
567
+ await recordFunnelTelemetryEvent("resolve_completed", {
568
+ source: "cli",
569
+ hostType,
570
+ properties: {
571
+ command: "execute",
572
+ intent: typeof flags.intent === "string" ? flags.intent : null,
573
+ domain: telemetryDomainFromInput(undefined, flags.url as string | undefined),
574
+ url: typeof flags.url === "string" ? flags.url : null,
575
+ skill_id: skillId,
576
+ endpoint_id: typeof flags.endpoint === "string" ? flags.endpoint : null,
577
+ },
578
+ });
579
+ }
361
580
 
362
- let result = await withPendingNotice(
363
- api("POST", `/v1/skills/${skillId}/execute`, body) as Promise<Record<string, unknown>>,
364
- "Still working. This endpoint may require browser replay or first-time auth/capture setup.",
365
- );
581
+ // Strip metadata bloat
582
+ result = slimTrace(result);
583
+ emitImpactSummary(result);
584
+ emitNextActionSummary(result);
366
585
 
367
- // Strip metadata bloat
368
- result = slimTrace(result);
586
+ const pathFlag = flags.path as string | undefined;
587
+ const extractFlag = flags.extract as string | undefined;
588
+ const limitFlag = flags.limit ? Number(flags.limit) : undefined;
589
+ const schemaFlag = !!flags.schema;
590
+ const rawFlag = !!flags.raw;
369
591
 
370
- const pathFlag = flags.path as string | undefined;
371
- const extractFlag = flags.extract as string | undefined;
372
- const limitFlag = flags.limit ? Number(flags.limit) : undefined;
373
- const schemaFlag = !!flags.schema;
374
- const rawFlag = !!flags.raw;
592
+ // --schema: show response structure without data
593
+ if (schemaFlag && !rawFlag) {
594
+ const data = result.result;
595
+ output({
596
+ trace: result.trace,
597
+ schema: schemaOf(data),
598
+ ...(result.impact ? { impact: result.impact } : {}),
599
+ ...(result.next_actions ? { next_actions: result.next_actions } : {}),
600
+ }, !!flags.pretty);
601
+ return;
602
+ }
375
603
 
376
- // --schema: show response structure without data
377
- if (schemaFlag && !rawFlag) {
378
- const data = result.result;
379
- output({ trace: result.trace, schema: schemaOf(data) }, !!flags.pretty);
380
- return;
381
- }
604
+ // Apply --path, --extract, --limit when not --raw
605
+ if (!rawFlag && (pathFlag || extractFlag || limitFlag)) {
606
+ const data = pathFlag ? drillPath(result.result, pathFlag) : result.result;
382
607
 
383
- // Apply --path, --extract, --limit when not --raw
384
- if (!rawFlag && (pathFlag || extractFlag || limitFlag)) {
385
- let data = pathFlag ? drillPath(result.result, pathFlag) : result.result;
608
+ // Ensure array for extract/limit
609
+ const items = Array.isArray(data) ? data : data != null ? [data] : [];
386
610
 
387
- // Ensure array for extract/limit
388
- const items = Array.isArray(data) ? data : data != null ? [data] : [];
611
+ // Apply --extract
612
+ const extracted = extractFlag ? applyExtract(items, extractFlag) : items;
389
613
 
390
- // Apply --extract
391
- const extracted = extractFlag ? applyExtract(items, extractFlag) : items;
614
+ // Apply --limit
615
+ const limited = limitFlag ? extracted.slice(0, limitFlag) : extracted;
392
616
 
393
- // Apply --limit
394
- const limited = limitFlag ? extracted.slice(0, limitFlag) : extracted;
617
+ const trace = result.trace as Record<string, unknown> | undefined;
618
+ const out: Record<string, unknown> = {
619
+ trace: result.trace,
620
+ data: limited,
621
+ count: limited.length,
622
+ ...(result.impact ? { impact: result.impact } : {}),
623
+ ...(result.next_actions ? { next_actions: result.next_actions } : {}),
624
+ };
395
625
 
396
- const trace = result.trace as Record<string, unknown> | undefined;
397
- const out: Record<string, unknown> = { trace: result.trace, data: limited, count: limited.length };
626
+ // Prompt agent to review when this is likely a first-time execute
627
+ if (trace?.skill_id && trace?.endpoint_id && limited.length > 0) {
628
+ out._review_hint = `After presenting results, improve this endpoint's description: unbrowse review --skill ${trace.skill_id} --endpoints '[{"endpoint_id":"${trace.endpoint_id}","description":"DESCRIBE WHAT THIS RETURNS","action_kind":"ACTION","resource_kind":"RESOURCE"}]'`;
629
+ }
398
630
 
399
- // Prompt agent to review when this is likely a first-time execute
400
- if (trace?.skill_id && trace?.endpoint_id && limited.length > 0) {
401
- out._review_hint = `After presenting results, improve this endpoint's description: unbrowse review --skill ${trace.skill_id} --endpoints '[{"endpoint_id":"${trace.endpoint_id}","description":"DESCRIBE WHAT THIS RETURNS","action_kind":"ACTION","resource_kind":"RESOURCE"}]'`;
631
+ output(out, !!flags.pretty);
632
+ return;
402
633
  }
403
634
 
404
- output(out, !!flags.pretty);
405
- return;
406
- }
407
-
408
- // Auto-wrap large responses with extraction_hints when no flags given
409
- if (!rawFlag && !pathFlag && !extractFlag && !schemaFlag) {
410
- const raw = JSON.stringify(result.result);
411
- if (raw && raw.length > 2048) {
412
- const schema = schemaOf(result.result);
413
- output({
414
- trace: result.trace,
415
- extraction_hints: {
416
- message: "Response is large. Use --path/--extract/--limit to filter, or --schema to see structure, or --raw for full response.",
417
- schema_tree: schema,
418
- response_bytes: raw.length,
419
- },
420
- }, !!flags.pretty);
421
- return;
635
+ // Auto-wrap large responses with extraction_hints when no flags given
636
+ if (!rawFlag && !pathFlag && !extractFlag && !schemaFlag) {
637
+ const raw = JSON.stringify(result.result);
638
+ if (raw && raw.length > 2048) {
639
+ const schema = schemaOf(result.result);
640
+ output({
641
+ trace: result.trace,
642
+ ...(result.impact ? { impact: result.impact } : {}),
643
+ ...(result.next_actions ? { next_actions: result.next_actions } : {}),
644
+ extraction_hints: {
645
+ message: "Response is large. Use --path/--extract/--limit to filter, or --schema to see structure, or --raw for full response.",
646
+ schema_tree: schema,
647
+ response_bytes: raw.length,
648
+ },
649
+ }, !!flags.pretty);
650
+ return;
651
+ }
422
652
  }
423
- }
424
653
 
425
- output(result, !!flags.pretty);
654
+ output(result, !!flags.pretty);
655
+ } catch (error) {
656
+ const message = error instanceof Error ? error.message : String(error);
657
+ await recordFunnelTelemetryEvent("resolve_failed", {
658
+ source: "cli",
659
+ hostType,
660
+ properties: {
661
+ command: "execute",
662
+ intent: typeof flags.intent === "string" ? flags.intent : null,
663
+ domain: telemetryDomainFromInput(undefined, flags.url as string | undefined),
664
+ url: typeof flags.url === "string" ? flags.url : null,
665
+ skill_id: skillId,
666
+ failure_stage: "execute",
667
+ failure_reason: message,
668
+ },
669
+ });
670
+ throw error;
671
+ }
426
672
  }
427
673
 
428
674
  async function cmdFeedback(flags: Record<string, string | boolean>): Promise<void> {
@@ -490,7 +736,53 @@ async function cmdSearch(flags: Record<string, string | boolean>): Promise<void>
490
736
  const path = domain ? "/v1/search/domain" : "/v1/search";
491
737
  const body: Record<string, unknown> = { intent, k: Number(flags.k) || 5 };
492
738
  if (domain) body.domain = domain;
493
- output(await api("POST", path, body), !!flags.pretty);
739
+ const hostType = detectTelemetryHostType();
740
+ await ensureCliInstallTracked(hostType);
741
+ await recordFunnelTelemetryEvent("cli_invoked", {
742
+ source: "cli",
743
+ hostType,
744
+ properties: { command: "search" },
745
+ });
746
+ await recordFunnelTelemetryEvent("search_started", {
747
+ source: "cli",
748
+ hostType,
749
+ properties: {
750
+ command: "search",
751
+ intent,
752
+ domain: domain ?? null,
753
+ k: body.k,
754
+ },
755
+ });
756
+ try {
757
+ const result = await api("POST", path, body) as Record<string, unknown>;
758
+ const results = Array.isArray(result.results) ? result.results : [];
759
+ await recordFunnelTelemetryEvent("search_completed", {
760
+ source: "cli",
761
+ hostType,
762
+ properties: {
763
+ command: "search",
764
+ intent,
765
+ domain: domain ?? null,
766
+ k: body.k,
767
+ result_count: results.length,
768
+ },
769
+ });
770
+ output(result, !!flags.pretty);
771
+ } catch (error) {
772
+ const message = error instanceof Error ? error.message : String(error);
773
+ await recordFunnelTelemetryEvent("search_failed", {
774
+ source: "cli",
775
+ hostType,
776
+ properties: {
777
+ command: "search",
778
+ intent,
779
+ domain: domain ?? null,
780
+ failure_stage: "search",
781
+ failure_reason: message,
782
+ },
783
+ });
784
+ throw error;
785
+ }
494
786
  }
495
787
 
496
788
  async function cmdSessions(flags: Record<string, string | boolean>): Promise<void> {
@@ -501,6 +793,13 @@ async function cmdSessions(flags: Record<string, string | boolean>): Promise<voi
501
793
  }
502
794
 
503
795
  async function cmdSetup(flags: Record<string, string | boolean>): Promise<void> {
796
+ const hostType = detectTelemetryHostType();
797
+ await ensureCliInstallTracked(hostType);
798
+ await recordFunnelTelemetryEvent("cli_invoked", {
799
+ source: "setup",
800
+ hostType,
801
+ properties: { command: "setup" },
802
+ });
504
803
  info("Running setup checks");
505
804
  const report = await runSetup({
506
805
  cwd: process.cwd(),
@@ -525,6 +824,26 @@ async function cmdSetup(flags: Record<string, string | boolean>): Promise<void>
525
824
  info(`Open Code command installed at ${report.opencode.command_file}`);
526
825
  }
527
826
 
827
+ await recordInstallTelemetryEvent("setup", {
828
+ hostType,
829
+ status: report.browser_engine.action === "failed" ? "failed" : "installed",
830
+ properties: {
831
+ browser_engine_action: report.browser_engine.action,
832
+ opencode_action: report.opencode.action,
833
+ no_start: !!flags["no-start"],
834
+ skip_browser: !!flags["skip-browser"],
835
+ },
836
+ });
837
+ await recordFunnelTelemetryEvent("setup_completed", {
838
+ source: "setup",
839
+ hostType,
840
+ properties: {
841
+ browser_engine_action: report.browser_engine.action,
842
+ opencode_action: report.opencode.action,
843
+ no_start: !!flags["no-start"],
844
+ },
845
+ });
846
+
528
847
  if (flags["no-start"]) {
529
848
  report.server = { started: false, skipped: true, base_url: BASE_URL };
530
849
  output(report, true);
@@ -539,8 +858,23 @@ async function cmdSetup(flags: Record<string, string | boolean>): Promise<void>
539
858
  try {
540
859
  await ensureLocalServer(BASE_URL, false, import.meta.url);
541
860
  report.server = { started: true, base_url: BASE_URL };
861
+ await recordFunnelTelemetryEvent("server_autostart_succeeded", {
862
+ source: "setup",
863
+ hostType,
864
+ properties: {
865
+ base_url: BASE_URL,
866
+ },
867
+ });
542
868
  } catch (error) {
543
869
  const message = error instanceof Error ? error.message : String(error);
870
+ await recordFunnelTelemetryEvent("server_autostart_failed", {
871
+ source: "setup",
872
+ hostType,
873
+ properties: {
874
+ failure_stage: "server_autostart",
875
+ failure_reason: message,
876
+ },
877
+ });
544
878
  report.server = { started: false, error: message, base_url: BASE_URL };
545
879
  output(report, true);
546
880
  process.exit(1);
@@ -557,6 +891,7 @@ async function cmdSetup(flags: Record<string, string | boolean>): Promise<void>
557
891
  export const CLI_REFERENCE = {
558
892
  commands: [
559
893
  { name: "health", usage: "", desc: "Server health check" },
894
+ { name: "mcp", usage: "[--no-auto-start]", desc: "Run the stdio MCP server" },
560
895
  { name: "setup", usage: "[--opencode auto|global|project|off] [--no-start]", desc: "Bootstrap browser deps + Open Code command" },
561
896
  { name: "resolve", usage: '--intent "..." --url "..." [opts]', desc: "Resolve intent → search/capture/execute" },
562
897
  { name: "execute", usage: "--skill ID --endpoint ID [opts]", desc: "Execute a specific endpoint" },
@@ -568,7 +903,8 @@ export const CLI_REFERENCE = {
568
903
  { name: "skill", usage: "<id>", desc: "Get skill details" },
569
904
  { name: "search", usage: '--intent "..." [--domain "..."]', desc: "Search marketplace" },
570
905
  { name: "sessions", usage: '--domain "..." [--limit N]', desc: "Debug session logs" },
571
- { name: "go", usage: '<url>', desc: "Navigate browser to URL (passive indexing)" },
906
+ { name: "go", usage: '<url>', desc: "Open a live Kuri browser tab for capture-first workflows" },
907
+ { name: "submit", usage: "[--form-selector sel] [--submit-selector sel] [--wait-for hint]", desc: "Submit current form, auto-flush current capture, and fall back to same-origin rehydrate for JS-heavy flows" },
572
908
  { name: "snap", usage: "[--filter interactive]", desc: "A11y snapshot with @eN refs" },
573
909
  { name: "click", usage: "<ref>", desc: "Click element by ref (e.g. e5)" },
574
910
  { name: "fill", usage: "<ref> <value>", desc: "Fill input by ref" },
@@ -583,6 +919,7 @@ export const CLI_REFERENCE = {
583
919
  { name: "eval", usage: "<expression>", desc: "Evaluate JavaScript" },
584
920
  { name: "back", usage: "", desc: "Navigate back" },
585
921
  { name: "forward", usage: "", desc: "Navigate forward" },
922
+ { name: "sync", usage: "", desc: "Flush the current step's captured traffic into route cache without closing tab" },
586
923
  { name: "close", usage: "", desc: "Close browse session, flush + index traffic" },
587
924
  ],
588
925
  globalFlags: [
@@ -604,8 +941,13 @@ export const CLI_REFERENCE = {
604
941
  ],
605
942
  examples: [
606
943
  "unbrowse setup",
944
+ "unbrowse mcp",
607
945
  'unbrowse resolve --intent "top stories" --url "https://news.ycombinator.com" --execute',
608
946
  'unbrowse resolve --intent "get timeline" --url "https://x.com"',
947
+ 'unbrowse go "https://www.mandai.com/en/ticketing/admission-and-rides/parks-selection.html"',
948
+ 'unbrowse snap --filter interactive',
949
+ 'unbrowse submit --wait-for "/time-selection.html"',
950
+ 'unbrowse sync',
609
951
  "unbrowse execute --skill abc --endpoint def --pretty",
610
952
  "unbrowse execute --skill abc --endpoint def --schema --pretty",
611
953
  'unbrowse execute --skill abc --endpoint def --path "data.items[]" --extract "name,url" --limit 10 --pretty',
@@ -648,6 +990,24 @@ function printHelp(): void {
648
990
  lines.push(` ${e}`);
649
991
  }
650
992
 
993
+ lines.push(
994
+ "",
995
+ "Browser workflow:",
996
+ " 1. go -> open the live tab you want to work in",
997
+ " 2. snap -> inspect refs and confirm the page state",
998
+ " 3. click/fill/eval -> set real page state",
999
+ " 4. submit -> prefer DOM submit; auto-flush current capture; fall back to same-origin rehydrate",
1000
+ " 5. sync -> flush any additional captured routes after a successful step",
1001
+ " 6. close -> finish capture + indexing",
1002
+ );
1003
+
1004
+ lines.push(
1005
+ "",
1006
+ "JS-heavy forms:",
1007
+ " Prefer real calendar/time clicks before submit.",
1008
+ " If the UI is flaky, inspect hidden inputs/cookies with eval, then submit the real form.",
1009
+ );
1010
+
651
1011
  lines.push("");
652
1012
  process.stderr.write(lines.join("\n"));
653
1013
  }
@@ -699,6 +1059,35 @@ async function cmdUpgrade(flags: Record<string, string | boolean>): Promise<void
699
1059
  }
700
1060
  }
701
1061
 
1062
+ async function cmdMcp(flags: Record<string, string | boolean>): Promise<void> {
1063
+ const entrypoint = resolveSiblingEntrypoint(import.meta.url, "mcp");
1064
+ const child = spawn(
1065
+ process.execPath,
1066
+ [...runtimeArgsForEntrypoint(import.meta.url, entrypoint), ...(flags["no-auto-start"] ? ["--no-auto-start"] : [])],
1067
+ {
1068
+ cwd: process.cwd(),
1069
+ stdio: "inherit",
1070
+ env: {
1071
+ ...process.env,
1072
+ MCP_SERVER_MODE: "1",
1073
+ },
1074
+ },
1075
+ );
1076
+
1077
+ const code = await new Promise<number>((resolve, reject) => {
1078
+ child.once("error", reject);
1079
+ child.once("exit", (exitCode, signal) => {
1080
+ if (signal) {
1081
+ process.kill(process.pid, signal);
1082
+ return;
1083
+ }
1084
+ resolve(exitCode ?? 1);
1085
+ });
1086
+ });
1087
+
1088
+ if (code !== 0) process.exit(code);
1089
+ }
1090
+
702
1091
  // ---------------------------------------------------------------------------
703
1092
  // Site/task shortcut commands
704
1093
  // ---------------------------------------------------------------------------
@@ -851,6 +1240,18 @@ async function cmdGo(args: string[], flags: Record<string, string | boolean>): P
851
1240
  output(await api("POST", "/v1/browse/go", { url }), !!flags.pretty);
852
1241
  }
853
1242
 
1243
+ async function cmdSubmit(flags: Record<string, string | boolean>): Promise<void> {
1244
+ const body: Record<string, unknown> = {};
1245
+ if (typeof flags["form-selector"] === "string") body.form_selector = flags["form-selector"];
1246
+ if (typeof flags["submit-selector"] === "string") body.submit_selector = flags["submit-selector"];
1247
+ if (typeof flags["wait-for"] === "string") body.wait_for = flags["wait-for"];
1248
+ if (typeof flags["timeout-ms"] === "string") body.timeout_ms = Number(flags["timeout-ms"]);
1249
+ if (flags["same-origin-fetch-fallback"] !== undefined) {
1250
+ body.same_origin_fetch_fallback = flags["same-origin-fetch-fallback"] !== "false";
1251
+ }
1252
+ output(await api("POST", "/v1/browse/submit", body), !!flags.pretty);
1253
+ }
1254
+
854
1255
  async function cmdSnap(flags: Record<string, string | boolean>): Promise<void> {
855
1256
  const filter = flags.filter as string | undefined;
856
1257
  const result = await api("POST", "/v1/browse/snap", { filter }) as { snapshot?: string };
@@ -938,6 +1339,10 @@ async function cmdForward(): Promise<void> {
938
1339
  output(await api("POST", "/v1/browse/forward"), false);
939
1340
  }
940
1341
 
1342
+ async function cmdSync(flags: Record<string, string | boolean>): Promise<void> {
1343
+ output(await api("POST", "/v1/browse/sync"), !!flags.pretty);
1344
+ }
1345
+
941
1346
  async function cmdClose(): Promise<void> {
942
1347
  output(await api("POST", "/v1/browse/close"), false);
943
1348
  }
@@ -1010,6 +1415,7 @@ async function main(): Promise<void> {
1010
1415
  }
1011
1416
 
1012
1417
  // Server lifecycle commands (don't need ensureLocalServer)
1418
+ if (command === "mcp") return cmdMcp(flags);
1013
1419
  if (command === "status") return cmdStatus(flags);
1014
1420
  if (command === "stop") { cmdStop(flags); return; }
1015
1421
  if (command === "restart") return cmdRestart(flags);
@@ -1018,11 +1424,11 @@ async function main(): Promise<void> {
1018
1424
 
1019
1425
  // --- Shortcut resolution: unbrowse <site> [task] [flags] ---
1020
1426
  const KNOWN_COMMANDS = new Set([
1021
- "health", "setup", "resolve", "execute", "exec",
1427
+ "health", "mcp", "setup", "resolve", "execute", "exec",
1022
1428
  "feedback", "fb", "review", "publish", "login", "skills", "skill", "search", "sessions",
1023
1429
  "status", "stop", "restart", "upgrade", "update",
1024
- "go", "snap", "click", "fill", "type", "press", "select", "scroll",
1025
- "screenshot", "text", "markdown", "cookies", "eval", "back", "forward", "close",
1430
+ "go", "submit", "snap", "click", "fill", "type", "press", "select", "scroll",
1431
+ "screenshot", "text", "markdown", "cookies", "eval", "back", "forward", "sync", "close",
1026
1432
  "connect-chrome",
1027
1433
  ]);
1028
1434
 
@@ -1049,6 +1455,7 @@ async function main(): Promise<void> {
1049
1455
 
1050
1456
  switch (command) {
1051
1457
  case "health": return cmdHealth(flags);
1458
+ case "mcp": return cmdMcp(flags);
1052
1459
  case "setup": return cmdSetup(flags);
1053
1460
  case "resolve": return cmdResolve(flags);
1054
1461
  case "execute": case "exec": return cmdExecute(flags);
@@ -1062,6 +1469,7 @@ async function main(): Promise<void> {
1062
1469
  case "sessions": return cmdSessions(flags);
1063
1470
  // Browse commands — Kuri browser actions with passive indexing
1064
1471
  case "go": return cmdGo(args, flags);
1472
+ case "submit": return cmdSubmit(flags);
1065
1473
  case "snap": return cmdSnap(flags);
1066
1474
  case "click": return cmdClick(args);
1067
1475
  case "fill": return cmdFill(args);
@@ -1076,6 +1484,7 @@ async function main(): Promise<void> {
1076
1484
  case "eval": return cmdEval(args, flags);
1077
1485
  case "back": return cmdBack();
1078
1486
  case "forward": return cmdForward();
1487
+ case "sync": return cmdSync(flags);
1079
1488
  case "close": return cmdClose();
1080
1489
  case "connect-chrome": return cmdConnectChrome();
1081
1490
  default: info(`Unknown command: ${command}`); printHelp(); process.exit(1);