surf-cli 2.5.2 → 2.7.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.
@@ -143,7 +143,15 @@ function httpsGet(url, headers, opts = {}) {
143
143
  }
144
144
 
145
145
  function httpsPost(url, headers, body, opts = {}) {
146
- const { timeoutMs = 30000, log = null, label = "httpsPost" } = opts;
146
+ return httpsSend("POST", url, headers, body, opts);
147
+ }
148
+
149
+ function httpsPut(url, headers, body, opts = {}) {
150
+ return httpsSend("PUT", url, headers, body, opts);
151
+ }
152
+
153
+ function httpsSend(method, url, headers, body, opts = {}) {
154
+ const { timeoutMs = 30000, log = null, label = "httpsSend" } = opts;
147
155
  return new Promise((resolve, reject) => {
148
156
  const urlObj = new URL(url);
149
157
  const bodyBuffer = body == null
@@ -156,7 +164,7 @@ function httpsPost(url, headers, body, opts = {}) {
156
164
  hostname: urlObj.hostname,
157
165
  port: 443,
158
166
  path: urlObj.pathname + urlObj.search,
159
- method: "POST",
167
+ method,
160
168
  headers: {
161
169
  "user-agent": USER_AGENT,
162
170
  ...headers,
@@ -224,6 +232,32 @@ async function fetchGeminiAccessToken(cookieMap, opts = {}) {
224
232
  }
225
233
 
226
234
  function trimGeminiJsonEnvelope(text) {
235
+ // Handle streaming chunk format: )]}\n\n<size>\n<json>\n<size>\n<json>...
236
+ const lines = text.split("\n");
237
+ const chunks = [];
238
+ for (let i = 0; i < lines.length; i++) {
239
+ const line = lines[i].trim();
240
+ if (!line || line === ")]}'" || /^\d+$/.test(line)) continue;
241
+ if (line.startsWith("[")) {
242
+ chunks.push(line);
243
+ }
244
+ }
245
+
246
+ if (chunks.length > 1) {
247
+ const merged = [];
248
+ for (const chunk of chunks) {
249
+ try {
250
+ const parsed = JSON.parse(chunk);
251
+ if (Array.isArray(parsed)) {
252
+ merged.push(...parsed);
253
+ }
254
+ } catch {
255
+ // ignore
256
+ }
257
+ }
258
+ return JSON.stringify(merged);
259
+ }
260
+
227
261
  const start = text.indexOf("[");
228
262
  const end = text.lastIndexOf("]");
229
263
  if (start === -1 || end === -1 || end <= start) {
@@ -347,32 +381,48 @@ function parseGeminiStreamGenerateResponse(rawText) {
347
381
  // File Upload
348
382
  // ============================================================================
349
383
 
350
- async function uploadGeminiFile(filePath, opts = {}) {
384
+ async function uploadGeminiFile(filePath, cookieMap, opts = {}) {
351
385
  const absPath = path.resolve(process.cwd(), filePath);
352
386
  const data = fs.readFileSync(absPath);
353
387
  const fileName = path.basename(absPath);
354
-
355
- // Build multipart form data manually
356
- const boundary = "----FormBoundary" + Math.random().toString(36).slice(2);
357
- const header = `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
358
- const footer = `\r\n--${boundary}--\r\n`;
359
-
360
- const body = Buffer.concat([
361
- Buffer.from(header, "utf-8"),
362
- data,
363
- Buffer.from(footer, "utf-8"),
364
- ]);
365
-
366
- const res = await httpsPost(GEMINI_UPLOAD_URL, {
367
- "content-type": `multipart/form-data; boundary=${boundary}`,
388
+ const cookieHeader = buildCookieHeader(cookieMap);
389
+
390
+ // Step 1: Initiate resumable upload
391
+ const initRes = await httpsPut(GEMINI_UPLOAD_URL, {
392
+ "authorization": "Basic c2F2ZXM6cyNMdGhlNmxzd2F2b0RsN3J1d1U=",
393
+ "content-type": "application/x-www-form-urlencoded;charset=UTF-8",
394
+ "cookie": cookieHeader,
368
395
  "push-id": GEMINI_UPLOAD_PUSH_ID,
369
- }, body, { ...opts, label: opts.label || "geminiUpload" });
396
+ "referer": "https://gemini.google.com/",
397
+ "x-goog-upload-command": "start",
398
+ "x-goog-upload-header-content-length": String(data.length),
399
+ "x-goog-upload-header-content-type": "application/octet-stream",
400
+ "x-goog-upload-protocol": "resumable",
401
+ "x-tenant-id": "bard-storage",
402
+ }, data, { ...opts, label: opts.label || "geminiUploadInit" });
403
+
404
+ const uploadId = initRes.headers["x-guploader-uploadid"];
405
+ if (!uploadId) {
406
+ throw new Error(`File upload init failed: no upload ID (${initRes.status})`);
407
+ }
370
408
 
371
- if (res.status < 200 || res.status >= 300) {
372
- throw new Error(`File upload failed: ${res.status} (${res.text.slice(0, 200)})`);
409
+ // Step 2: Upload data and finalize
410
+ const uploadUrl = `${GEMINI_UPLOAD_URL}?upload_id=${encodeURIComponent(uploadId)}&upload_protocol=resumable`;
411
+ const finalRes = await httpsPut(uploadUrl, {
412
+ "content-type": "application/octet-stream",
413
+ "cookie": cookieHeader,
414
+ "origin": "https://gemini.google.com",
415
+ "referer": "https://gemini.google.com/",
416
+ "x-goog-upload-command": "upload, finalize",
417
+ "x-goog-upload-offset": "0",
418
+ "x-tenant-id": "bard-storage",
419
+ }, data, { ...opts, label: opts.label || "geminiUpload" });
420
+
421
+ if (finalRes.status < 200 || finalRes.status >= 300) {
422
+ throw new Error(`File upload failed: ${finalRes.status} (${finalRes.text.slice(0, 200)})`);
373
423
  }
374
424
 
375
- return { id: res.text, name: fileName };
425
+ return { id: finalRes.text, name: fileName };
376
426
  }
377
427
 
378
428
  // ============================================================================
@@ -402,18 +452,51 @@ async function downloadGeminiImage(url, cookieMap, outputPath, opts = {}) {
402
452
  fs.writeFileSync(outputPath, res.buffer);
403
453
  }
404
454
 
455
+ async function downloadGeminiImageViaExtension(url, outputPath, opts = {}) {
456
+ const { fetchUrl, log } = opts;
457
+ const fullUrl = ensureFullSizeImageUrl(url);
458
+
459
+ const result = await fetchUrl(fullUrl);
460
+ if (!result || result.error) throw new Error(`Image download failed: ${result?.error || "no response"}`);
461
+ if (!result.b64) throw new Error("Image download returned no data");
462
+
463
+ const dir = path.dirname(outputPath);
464
+ if (dir && !fs.existsSync(dir)) {
465
+ fs.mkdirSync(dir, { recursive: true });
466
+ }
467
+
468
+ fs.writeFileSync(outputPath, Buffer.from(result.b64, "base64"));
469
+ }
470
+
405
471
  async function saveFirstGeminiImage(output, cookieMap, outputPath, opts = {}) {
406
- // Try generated or web images first
407
- const genOrWeb = output.images.find(img => img.kind === "generated") ?? output.images[0];
408
- if (genOrWeb?.url) {
409
- await downloadGeminiImage(genOrWeb.url, cookieMap, outputPath, opts);
472
+ const useExtensionDownload = !!opts.fetchUrl;
473
+ const img = output.images?.find(i => i.kind === "generated") ?? output.images?.[0];
474
+
475
+ if (img?.b64) {
476
+ const dir = path.dirname(outputPath);
477
+ if (dir && !fs.existsSync(dir)) {
478
+ fs.mkdirSync(dir, { recursive: true });
479
+ }
480
+ fs.writeFileSync(outputPath, Buffer.from(img.b64, "base64"));
481
+ return { saved: true, imageCount: output.images.length };
482
+ }
483
+
484
+ if (img?.url) {
485
+ if (useExtensionDownload) {
486
+ await downloadGeminiImageViaExtension(img.url, outputPath, opts);
487
+ } else {
488
+ await downloadGeminiImage(img.url, cookieMap, outputPath, opts);
489
+ }
410
490
  return { saved: true, imageCount: output.images.length };
411
491
  }
412
492
 
413
- // Fall back to gg-dl URLs in raw response
414
- const ggdl = extractGgdlUrls(output.rawResponseText);
493
+ const ggdl = extractGgdlUrls(output.rawResponseText || "");
415
494
  if (ggdl[0]) {
416
- await downloadGeminiImage(ggdl[0], cookieMap, outputPath, opts);
495
+ if (useExtensionDownload) {
496
+ await downloadGeminiImageViaExtension(ggdl[0], outputPath, opts);
497
+ } else {
498
+ await downloadGeminiImage(ggdl[0], cookieMap, outputPath, opts);
499
+ }
417
500
  return { saved: true, imageCount: ggdl.length };
418
501
  }
419
502
 
@@ -426,7 +509,7 @@ async function saveFirstGeminiImage(output, cookieMap, outputPath, opts = {}) {
426
509
 
427
510
  function buildGeminiFReqPayload(prompt, uploaded, chatMetadata) {
428
511
  const promptPayload = uploaded.length > 0
429
- ? [prompt, 0, null, uploaded.map(file => [[file.id, 1]])]
512
+ ? [prompt, 0, null, uploaded.map(file => [[file.id, 1], file.name])]
430
513
  : [prompt];
431
514
 
432
515
  const innerList = [promptPayload, null, chatMetadata ?? null];
@@ -443,7 +526,7 @@ async function runGeminiWebOnce(input) {
443
526
  // 2. Upload files
444
527
  const uploaded = [];
445
528
  for (const file of files ?? []) {
446
- uploaded.push(await uploadGeminiFile(file, { timeoutMs, log, label: "geminiUpload" }));
529
+ uploaded.push(await uploadGeminiFile(file, cookieMap, { timeoutMs, log, label: "geminiUpload" }));
447
530
  }
448
531
 
449
532
  // 3. Build request
@@ -519,6 +602,137 @@ async function runGeminiWebWithFallback(input) {
519
602
  return { ...attempt, effectiveModel: input.model };
520
603
  }
521
604
 
605
+ // ============================================================================
606
+ // In-Page Execution (for image generation)
607
+ // ============================================================================
608
+
609
+ async function runGeminiWebViaPage(input) {
610
+ const { prompt, files, model, timeoutMs = 120000, log = null, createTab, closeTab, jsEval, fetchUrl, uploadFile } = input;
611
+
612
+ if (!createTab || !closeTab || !jsEval) {
613
+ throw new Error("In-page execution requires createTab, closeTab, and jsEval callbacks");
614
+ }
615
+
616
+ let tabId = null;
617
+ try {
618
+ if (log) log("Creating Gemini tab...");
619
+ const tabResult = await createTab();
620
+ tabId = tabResult?.tabId;
621
+ if (!tabId) throw new Error("Failed to create Gemini tab");
622
+ if (log) log(`Gemini tab created: ${tabId}`);
623
+ await new Promise(r => setTimeout(r, 12000));
624
+
625
+ if (files?.length && uploadFile) {
626
+ const absFiles = files.map(f => path.resolve(process.cwd(), f));
627
+ if (log) log(`Uploading ${absFiles.length} file(s) via file chooser...`);
628
+ const result = await uploadFile(tabId, absFiles);
629
+ if (result?.error) throw new Error(`File upload failed: ${result.error}`);
630
+ if (log) log("File uploaded, waiting for processing...");
631
+ await new Promise(r => setTimeout(r, 3000));
632
+ }
633
+
634
+ const checkJsResult = (result, context) => {
635
+ if (result?.error) throw new Error(`${context}: ${result.error}`);
636
+ if (result?.output === undefined) throw new Error(`${context}: no output`);
637
+ return result.output;
638
+ };
639
+
640
+ // Type prompt
641
+ const fullPrompt = prompt.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "\\r");
642
+ if (log) log("Typing prompt...");
643
+ const typeResult = await jsEval(tabId, `
644
+ const editor = document.querySelector('.ql-editor[contenteditable=true]');
645
+ if (!editor) return JSON.stringify({ error: "No editor found on page" });
646
+ editor.focus();
647
+ document.execCommand('selectAll', false, null);
648
+ document.execCommand('insertText', false, '${fullPrompt}');
649
+ return JSON.stringify({ ok: true, len: editor.textContent.length });
650
+ `);
651
+ const typed = JSON.parse(JSON.parse(checkJsResult(typeResult, "Type prompt")));
652
+ if (typed.error) throw new Error(typed.error);
653
+
654
+ const beforeResult = await jsEval(tabId, `
655
+ return String(Array.from(document.querySelectorAll('img[src*="gg-dl"]')).filter(i => i.naturalWidth >= 512).length);
656
+ `);
657
+ const imgCountBefore = parseInt(JSON.parse(checkJsResult(beforeResult, "Count images")) || "0", 10);
658
+
659
+ if (log) log("Submitting...");
660
+ const sendResult = await jsEval(tabId, `
661
+ const btn = document.querySelector('button[aria-label="Send message"]');
662
+ if (!btn) return 'no-btn';
663
+ btn.click();
664
+ return 'sent';
665
+ `);
666
+ const sendVal = JSON.parse(checkJsResult(sendResult, "Click send"));
667
+ if (sendVal === "no-btn") throw new Error("Send button not found on Gemini page");
668
+
669
+ // Poll for response
670
+ if (log) log("Waiting for response...");
671
+ const deadline = Date.now() + timeoutMs;
672
+ let imageUrls = [];
673
+ let responseText = "";
674
+
675
+ while (Date.now() < deadline) {
676
+ await new Promise(r => setTimeout(r, 2000));
677
+ const pollResult = await jsEval(tabId, `
678
+ const ggImgs = Array.from(document.querySelectorAll('img[src*="gg-dl"]'))
679
+ .filter(i => i.naturalWidth >= 512)
680
+ .map(i => i.src);
681
+ const loading = !!document.querySelector('mat-progress-bar, .loading-indicator, message-loading');
682
+ const turns = document.querySelectorAll('message-content');
683
+ const lastTurn = turns.length ? turns[turns.length - 1] : null;
684
+ const text = lastTurn ? lastTurn.textContent?.trim() : "";
685
+ return JSON.stringify({ ggImgs, loading, text, turns: turns.length });
686
+ `);
687
+ const poll = JSON.parse(JSON.parse(checkJsResult(pollResult, "Poll response")));
688
+ const newImgs = poll.ggImgs.slice(imgCountBefore);
689
+
690
+ if (newImgs.length > 0) {
691
+ imageUrls = newImgs;
692
+ responseText = poll.text || "";
693
+ if (log) log(`Found ${newImgs.length} generated image(s)`);
694
+ break;
695
+ }
696
+ if (!poll.loading && poll.text && poll.turns > 0) {
697
+ responseText = poll.text;
698
+ break;
699
+ }
700
+ }
701
+
702
+ if (!imageUrls.length && !responseText) {
703
+ throw new Error("Gemini response timed out");
704
+ }
705
+
706
+ // Download images via extension
707
+ const images = [];
708
+ if (fetchUrl) {
709
+ for (const url of imageUrls) {
710
+ if (log) log(`Downloading image (${url.slice(0, 60)}...)...`);
711
+ const dlResult = await fetchUrl(url);
712
+ if (dlResult?.b64) {
713
+ images.push({ url, b64: dlResult.b64, type: dlResult.type || "image/png" });
714
+ }
715
+ }
716
+ } else {
717
+ for (const url of imageUrls) {
718
+ images.push({ url });
719
+ }
720
+ }
721
+
722
+ return {
723
+ text: responseText,
724
+ thoughts: null,
725
+ metadata: null,
726
+ images,
727
+ effectiveModel: model,
728
+ _pageTabId: tabId,
729
+ };
730
+ } catch (err) {
731
+ if (tabId) { try { await closeTab(tabId); } catch {} }
732
+ throw err;
733
+ }
734
+ }
735
+
522
736
  // ============================================================================
523
737
  // Main Query Function
524
738
  // ============================================================================
@@ -534,9 +748,15 @@ async function query(options) {
534
748
  youtube,
535
749
  aspectRatio,
536
750
  getCookies,
751
+ createTab,
752
+ closeTab,
753
+ jsEval,
754
+ fetchUrl,
755
+ uploadFile,
537
756
  timeout = 300000,
538
757
  log = () => {},
539
758
  } = options;
759
+ const hasPageCallbacks = !!(createTab && closeTab && jsEval);
540
760
 
541
761
  const startTime = Date.now();
542
762
  log("Starting Gemini query");
@@ -579,59 +799,80 @@ async function query(options) {
579
799
 
580
800
  try {
581
801
  if (editImage) {
582
- // Two-turn conversation for image editing
583
- log("Uploading image for editing...");
584
- const intro = await runGeminiWebWithFallback({
585
- prompt: "Here is an image to edit",
802
+ // Image editing
803
+ if (!hasPageCallbacks) {
804
+ throw new Error("Image editing requires the Chrome extension. Make sure it's loaded.");
805
+ }
806
+
807
+ log("Uploading and editing image...");
808
+ const out = await runGeminiWebViaPage({
809
+ prompt: fullPrompt,
586
810
  files: [editImage],
587
811
  model: resolvedModel,
588
- cookieMap,
589
- chatMetadata: null,
590
- timeoutMs: timeout,
591
- log,
592
- });
593
-
594
- log("Sending edit request...");
595
- const editPrompt = `Use image generation tool to ${fullPrompt}`;
596
- const out = await runGeminiWebWithFallback({
597
- prompt: editPrompt,
598
- files,
599
- model: resolvedModel,
600
- cookieMap,
601
- chatMetadata: intro.metadata,
602
812
  timeoutMs: timeout,
603
813
  log,
814
+ createTab,
815
+ closeTab,
816
+ jsEval,
817
+ fetchUrl,
818
+ uploadFile,
604
819
  });
605
820
 
606
821
  response = out;
607
822
 
608
823
  // Save output image
609
824
  const outputPath = output || generateImage || "edited.png";
610
- const imageSave = await saveFirstGeminiImage(out, cookieMap, outputPath, { timeoutMs: timeout, log });
611
- if (!imageSave.saved) {
612
- throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
825
+ const saveOpts = { timeoutMs: timeout, log };
826
+ if (fetchUrl) saveOpts.fetchUrl = fetchUrl;
827
+ try {
828
+ const imageSave = await saveFirstGeminiImage(out, cookieMap, outputPath, saveOpts);
829
+ if (!imageSave.saved) {
830
+ throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
831
+ }
832
+ } finally {
833
+ if (out._pageTabId && closeTab) { try { await closeTab(out._pageTabId); } catch {} }
613
834
  }
614
835
  imagePath = outputPath;
615
836
 
616
837
  } else if (generateImage) {
617
838
  // Image generation
618
839
  log("Generating image...");
619
- const out = await runGeminiWebWithFallback({
620
- prompt: fullPrompt,
621
- files,
622
- model: resolvedModel,
623
- cookieMap,
624
- chatMetadata: null,
625
- timeoutMs: timeout,
626
- log,
627
- });
840
+ let out;
841
+ if (hasPageCallbacks) {
842
+ out = await runGeminiWebViaPage({
843
+ prompt: fullPrompt,
844
+ model: resolvedModel,
845
+ timeoutMs: timeout,
846
+ log,
847
+ createTab,
848
+ closeTab,
849
+ jsEval,
850
+ fetchUrl,
851
+ });
852
+ } else {
853
+ out = await runGeminiWebWithFallback({
854
+ prompt: fullPrompt,
855
+ files,
856
+ model: resolvedModel,
857
+ cookieMap,
858
+ chatMetadata: null,
859
+ timeoutMs: timeout,
860
+ log,
861
+ });
862
+ }
628
863
 
629
864
  response = out;
630
865
 
631
866
  // Save output image
632
- const imageSave = await saveFirstGeminiImage(out, cookieMap, generateImage, { timeoutMs: timeout, log });
633
- if (!imageSave.saved) {
634
- throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
867
+ const saveOpts = { timeoutMs: timeout, log };
868
+ if (fetchUrl) saveOpts.fetchUrl = fetchUrl;
869
+ try {
870
+ const imageSave = await saveFirstGeminiImage(out, cookieMap, generateImage, saveOpts);
871
+ if (!imageSave.saved) {
872
+ throw new Error(`No images generated. Response: ${out.text?.slice(0, 200) || "(empty)"}`);
873
+ }
874
+ } finally {
875
+ if (out._pageTabId && closeTab) { try { await closeTab(out._pageTabId); } catch {} }
635
876
  }
636
877
  imagePath = generateImage;
637
878
 
@@ -2,6 +2,10 @@ const fs = require("fs");
2
2
  const networkFormatters = require("./formatters/network.cjs");
3
3
  const networkStore = require("./network-store.cjs");
4
4
 
5
+ function normalizeModelString(model) {
6
+ return String(model || "").trim().toLowerCase();
7
+ }
8
+
5
9
  /**
6
10
  * Format tool result content for MCP response
7
11
  * @param {*} result - The result object from the extension
@@ -1070,6 +1074,31 @@ function mapToolToMessage(tool, args, tabId) {
1070
1074
  timeout: a.timeout ? parseInt(a.timeout, 10) * 1000 : 300000,
1071
1075
  ...baseMsg
1072
1076
  };
1077
+ case "aistudio": {
1078
+ if (!a.query) throw new Error("query required");
1079
+
1080
+ return {
1081
+ type: "AISTUDIO_QUERY",
1082
+ query: a.query,
1083
+ model: a.model ? normalizeModelString(a.model) : undefined,
1084
+ withPage: a["with-page"],
1085
+ timeout: a.timeout ? parseInt(a.timeout, 10) * 1000 : 300000,
1086
+ ...baseMsg
1087
+ };
1088
+ }
1089
+ case "aistudio.build": {
1090
+ if (!a.query) throw new Error("query required");
1091
+
1092
+ return {
1093
+ type: "AISTUDIO_BUILD",
1094
+ query: a.query,
1095
+ model: a.model ? normalizeModelString(a.model) : undefined,
1096
+ output: a.output,
1097
+ keepOpen: Boolean(a["keep-open"] || a.keepOpen),
1098
+ timeout: a.timeout ? parseInt(a.timeout, 10) * 1000 : 600000,
1099
+ ...baseMsg,
1100
+ };
1101
+ }
1073
1102
  case "window.new":
1074
1103
  return {
1075
1104
  type: "WINDOW_NEW",