surf-cli 2.1.0 → 2.3.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.
@@ -0,0 +1,906 @@
1
+ /**
2
+ * Grok Web Client for surf-cli
3
+ *
4
+ * CDP-based client for X.com's Grok AI using browser automation.
5
+ * Provides access to Grok's unique real-time X/Twitter data capabilities.
6
+ */
7
+
8
+ const { loadConfig, getConfigPath, clearCache } = require("./config.cjs");
9
+
10
+ const GROK_URL = "https://x.com/i/grok";
11
+ const DEFAULT_MODEL = "thinking";
12
+
13
+ // Default models (as of Jan 2026)
14
+ const DEFAULT_GROK_MODELS = {
15
+ "auto": { id: "auto", name: "Auto", desc: "Chooses Fast or Expert" },
16
+ "fast": { id: "fast", name: "Fast", desc: "Quick responses" },
17
+ "expert": { id: "expert", name: "Expert", desc: "Thinks hard" },
18
+ "thinking": { id: "thinking", name: "Grok 4.1 Thinking", desc: "Thinks fast" },
19
+ };
20
+
21
+ // Load models from surf.json config or use defaults
22
+ function getGrokModels() {
23
+ try {
24
+ const config = loadConfig();
25
+ if (config.grok?.models && typeof config.grok.models === "object" && Object.keys(config.grok.models).length > 0) {
26
+ return config.grok.models;
27
+ }
28
+ } catch (e) {
29
+ // Ignore errors, use defaults
30
+ }
31
+ return DEFAULT_GROK_MODELS;
32
+ }
33
+
34
+ // For backwards compatibility
35
+ const GROK_MODELS = DEFAULT_GROK_MODELS;
36
+
37
+ // ============================================================================
38
+ // Helpers
39
+ // ============================================================================
40
+
41
+ function delay(ms) {
42
+ return new Promise(resolve => setTimeout(resolve, ms));
43
+ }
44
+
45
+ function buildClickDispatcher() {
46
+ return `function dispatchClickSequence(target) {
47
+ if (!target || !(target instanceof EventTarget)) return false;
48
+ const types = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'];
49
+ for (const type of types) {
50
+ const common = { bubbles: true, cancelable: true, view: window };
51
+ let event;
52
+ if (type.startsWith('pointer') && 'PointerEvent' in window) {
53
+ event = new PointerEvent(type, { ...common, pointerId: 1, pointerType: 'mouse' });
54
+ } else {
55
+ event = new MouseEvent(type, common);
56
+ }
57
+ target.dispatchEvent(event);
58
+ }
59
+ return true;
60
+ }`;
61
+ }
62
+
63
+ function hasRequiredCookies(cookies) {
64
+ if (!cookies || !Array.isArray(cookies)) return false;
65
+ // auth_token is the primary session cookie for X.com
66
+ // ct0 is a CSRF token that's set dynamically, not strictly required for page load
67
+ const authToken = cookies.find(c => c.name === "auth_token" && c.value);
68
+ return Boolean(authToken);
69
+ }
70
+
71
+ async function evaluate(cdp, expression) {
72
+ const result = await cdp(expression);
73
+ if (result.exceptionDetails) {
74
+ const desc = result.exceptionDetails.exception?.description ||
75
+ result.exceptionDetails.text ||
76
+ "Evaluation failed";
77
+ throw new Error(desc);
78
+ }
79
+ if (result.error) {
80
+ throw new Error(result.error);
81
+ }
82
+ return result.result?.value;
83
+ }
84
+
85
+ // ============================================================================
86
+ // Page State Functions
87
+ // ============================================================================
88
+
89
+ async function waitForPageLoad(cdp, timeoutMs = 30000) {
90
+ const deadline = Date.now() + timeoutMs;
91
+ while (Date.now() < deadline) {
92
+ const ready = await evaluate(cdp, "document.readyState");
93
+ if (ready === "complete" || ready === "interactive") {
94
+ // Extra wait for X.com's React app to hydrate
95
+ await delay(1500);
96
+ return;
97
+ }
98
+ await delay(100);
99
+ }
100
+ throw new Error("Page did not load in time");
101
+ }
102
+
103
+ async function checkLoginStatus(cdp) {
104
+ const result = await evaluate(cdp, `(() => {
105
+ const body = document.body.innerText.toLowerCase();
106
+ const hasLoginButton = !!document.querySelector('a[href*="/login"], [data-testid="loginButton"]');
107
+ const hasGrokUI = body.includes('ask anything') || body.includes('grok');
108
+ const hasPremiumPrompt = body.includes('subscribe') || body.includes('premium required');
109
+
110
+ return {
111
+ loggedIn: !hasLoginButton && hasGrokUI,
112
+ hasPremium: hasGrokUI && !hasPremiumPrompt,
113
+ url: location.href
114
+ };
115
+ })()`);
116
+
117
+ return result || { loggedIn: false, hasPremium: false };
118
+ }
119
+
120
+ async function waitForGrokReady(cdp, timeoutMs = 20000) {
121
+ const deadline = Date.now() + timeoutMs;
122
+ let lastState = null;
123
+
124
+ while (Date.now() < deadline) {
125
+ const state = await evaluate(cdp, `(() => {
126
+ // Check for Grok-specific elements
127
+ const hasInput = !!document.querySelector('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
128
+ const hasGrokBranding = document.body.innerText.includes('Grok') ||
129
+ !!document.querySelector('[data-testid*="grok"]');
130
+ const isGrokPage = location.pathname.includes('/grok');
131
+ const isLoginPage = location.pathname.includes('/login') || location.pathname.includes('/i/flow');
132
+
133
+ return {
134
+ ready: isGrokPage && (hasInput || hasGrokBranding),
135
+ hasInput,
136
+ isGrokPage,
137
+ isLoginPage,
138
+ url: location.href
139
+ };
140
+ })()`);
141
+
142
+ lastState = state;
143
+
144
+ if (state && state.ready) {
145
+ return state;
146
+ }
147
+
148
+ // If redirected to login, fail fast
149
+ if (state && state.isLoginPage) {
150
+ throw new Error("Redirected to login page - X.com login required");
151
+ }
152
+
153
+ await delay(200);
154
+ }
155
+
156
+ // Timeout - provide helpful error based on last state
157
+ if (lastState && !lastState.isGrokPage) {
158
+ throw new Error(`Not on Grok page (current: ${lastState.url}) - may need to log in`);
159
+ }
160
+
161
+ // Return fallback for edge cases where we're on Grok page but UI isn't detected
162
+ return { ready: true, fallback: true };
163
+ }
164
+
165
+ // ============================================================================
166
+ // Model Selection
167
+ // ============================================================================
168
+
169
+ async function selectModel(cdp, desiredModel, timeoutMs = 8000) {
170
+ const normalizedModel = desiredModel.toLowerCase().replace(/[^a-z0-9.-]/g, "");
171
+
172
+ // First, find and click the model selector button
173
+ const buttonClicked = await evaluate(cdp, `(() => {
174
+ ${buildClickDispatcher()}
175
+
176
+ // Look for model selector button (shows current model: Auto, Fast, Expert, or Grok 4.1 Thinking)
177
+ const buttons = Array.from(document.querySelectorAll('button'));
178
+ const modelBtn = buttons.find(b => {
179
+ const text = (b.textContent || '').toLowerCase();
180
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
181
+ const testId = (b.getAttribute('data-testid') || '').toLowerCase();
182
+ // Match model names or model-related attributes
183
+ const hasModelName = /^(auto|fast|expert|grok\\s*4)/i.test(text.trim());
184
+ const hasModelLabel = label.includes('model') || testId.includes('model');
185
+ return hasModelName || hasModelLabel;
186
+ });
187
+
188
+ if (!modelBtn) return { success: false, error: 'Model selector not found' };
189
+
190
+ dispatchClickSequence(modelBtn);
191
+ return { success: true };
192
+ })()`);
193
+
194
+ if (!buttonClicked || !buttonClicked.success) {
195
+ // Model selector might not exist (single model), continue anyway
196
+ return desiredModel;
197
+ }
198
+
199
+ await delay(400);
200
+
201
+ // Select from menu - loop in Node.js to avoid CDP timeout issues
202
+ const deadline = Date.now() + timeoutMs;
203
+
204
+ while (Date.now() < deadline) {
205
+ const result = await evaluate(cdp, `(() => {
206
+ ${buildClickDispatcher()}
207
+
208
+ const targetModel = ${JSON.stringify(normalizedModel)};
209
+ const normalize = (text) => (text || '').toLowerCase().replace(/[^a-z0-9.-]/g, '');
210
+
211
+ // Look for menu items
212
+ const items = document.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"]');
213
+
214
+ if (items.length === 0) {
215
+ return { found: false, waiting: true };
216
+ }
217
+
218
+ let bestMatch = null;
219
+ let bestScore = 0;
220
+
221
+ for (const item of items) {
222
+ const text = normalize(item.textContent || '');
223
+ let score = 0;
224
+
225
+ if (text.includes(targetModel)) score = 100;
226
+ else if (targetModel.includes(text) && text.length > 3) score = 50;
227
+ else if (text.includes('thinking') && targetModel.includes('thinking')) score = 75;
228
+
229
+ if (score > bestScore) {
230
+ bestScore = score;
231
+ bestMatch = item;
232
+ }
233
+ }
234
+
235
+ if (bestMatch) {
236
+ dispatchClickSequence(bestMatch);
237
+ return { found: true, success: true, model: bestMatch.textContent?.trim() };
238
+ }
239
+
240
+ return { found: true, success: false, error: 'No matching model in menu' };
241
+ })()`);
242
+
243
+ if (result && result.found) {
244
+ if (result.success) {
245
+ await delay(200);
246
+ return result.model;
247
+ }
248
+ // Items found but no match - close menu and return default
249
+ await evaluate(cdp, `document.body.click()`);
250
+ return desiredModel;
251
+ }
252
+
253
+ await delay(100);
254
+ }
255
+
256
+ // Timeout - close menu
257
+ await evaluate(cdp, `document.body.click()`);
258
+ return desiredModel;
259
+ }
260
+
261
+ // ============================================================================
262
+ // DeepSearch Toggle
263
+ // ============================================================================
264
+
265
+ async function enableDeepSearch(cdp) {
266
+ const result = await evaluate(cdp, `(() => {
267
+ ${buildClickDispatcher()}
268
+
269
+ // Look for DeepSearch toggle or button
270
+ const buttons = Array.from(document.querySelectorAll('button, [role="switch"]'));
271
+ const deepSearchBtn = buttons.find(b => {
272
+ const text = (b.textContent || '').toLowerCase();
273
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
274
+ // Be specific to avoid clicking unrelated search buttons
275
+ return text.includes('deepsearch') || text.includes('deep search') ||
276
+ label.includes('deepsearch') || label.includes('deep search');
277
+ });
278
+
279
+ if (!deepSearchBtn) {
280
+ return { success: false, error: 'DeepSearch toggle not found' };
281
+ }
282
+
283
+ // Check if already enabled
284
+ const isEnabled = deepSearchBtn.getAttribute('aria-checked') === 'true' ||
285
+ deepSearchBtn.classList.contains('active');
286
+
287
+ if (isEnabled) {
288
+ return { success: true, alreadyEnabled: true };
289
+ }
290
+
291
+ dispatchClickSequence(deepSearchBtn);
292
+ return { success: true };
293
+ })()`);
294
+
295
+ if (result && result.success) {
296
+ await delay(300);
297
+ }
298
+
299
+ return result || { success: false };
300
+ }
301
+
302
+ // ============================================================================
303
+ // Input and Submission
304
+ // ============================================================================
305
+
306
+ async function typePrompt(cdp, inputCdp, prompt) {
307
+ // Focus the input area
308
+ const focused = await evaluate(cdp, `(() => {
309
+ ${buildClickDispatcher()}
310
+
311
+ // Strategy 1: Find textarea or contenteditable
312
+ const inputs = document.querySelectorAll('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
313
+ for (const el of inputs) {
314
+ if (el.offsetParent !== null) {
315
+ dispatchClickSequence(el);
316
+ el.focus?.();
317
+ return { success: true, method: 'input' };
318
+ }
319
+ }
320
+
321
+ // Strategy 2: Look for elements with "Ask" placeholder (more targeted selector)
322
+ const placeholderEls = document.querySelectorAll('[placeholder*="Ask"], [placeholder*="ask"], [aria-placeholder*="Ask"]');
323
+ for (const el of placeholderEls) {
324
+ if (el.offsetParent !== null) {
325
+ dispatchClickSequence(el);
326
+ el.focus?.();
327
+ return { success: true, method: 'placeholder' };
328
+ }
329
+ }
330
+
331
+ return { success: false, error: 'Input not found' };
332
+ })()`);
333
+
334
+ if (!focused || !focused.success) {
335
+ throw new Error(`Could not focus input: ${focused?.error || 'unknown'}`);
336
+ }
337
+
338
+ await delay(300);
339
+
340
+ // Type using CDP Input API
341
+ await inputCdp("Input.insertText", { text: prompt });
342
+ await delay(200);
343
+ }
344
+
345
+ async function submitPrompt(cdp, inputCdp) {
346
+ // Try to click send button
347
+ const clicked = await evaluate(cdp, `(() => {
348
+ ${buildClickDispatcher()}
349
+
350
+ // Look for send button
351
+ const buttons = Array.from(document.querySelectorAll('button'));
352
+ const sendBtn = buttons.find(b => {
353
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
354
+ const testId = b.getAttribute('data-testid') || '';
355
+ return label.includes('send') || testId.includes('send') ||
356
+ testId.includes('submit') || testId.includes('grokSend');
357
+ });
358
+
359
+ if (sendBtn && !sendBtn.disabled) {
360
+ dispatchClickSequence(sendBtn);
361
+ return { success: true, method: 'button' };
362
+ }
363
+
364
+ return { success: false };
365
+ })()`);
366
+
367
+ if (!clicked || !clicked.success) {
368
+ // Fallback: press Enter
369
+ await inputCdp("Input.dispatchKeyEvent", {
370
+ type: "keyDown",
371
+ key: "Enter",
372
+ code: "Enter",
373
+ windowsVirtualKeyCode: 13,
374
+ nativeVirtualKeyCode: 13,
375
+ text: "\r",
376
+ });
377
+ await inputCdp("Input.dispatchKeyEvent", {
378
+ type: "keyUp",
379
+ key: "Enter",
380
+ code: "Enter",
381
+ windowsVirtualKeyCode: 13,
382
+ nativeVirtualKeyCode: 13,
383
+ });
384
+ }
385
+
386
+ await delay(500);
387
+ }
388
+
389
+ // ============================================================================
390
+ // Response Handling
391
+ // ============================================================================
392
+
393
+ // Extract Grok's response from the full page body text
394
+ function extractGrokResponse(bodyText, userPrompt = '') {
395
+ if (!bodyText) return null;
396
+
397
+ // Split into lines and filter out navigation/UI elements
398
+ const lines = bodyText.split('\n').map(l => l.trim()).filter(l => l);
399
+
400
+ // Known UI elements to skip
401
+ const uiPatterns = [
402
+ /^(Home|Explore|Notifications|Messages|Chat|Grok|Premium|Bookmarks|Communities|Profile|More|Post)$/i,
403
+ /^(Creator Studio|Lists|Verified Orgs)$/i,
404
+ /^(History|Private|Create Images|Edit Image|Latest News)$/i,
405
+ /^(Create recurring tasks|Get access to|Explore)$/i,
406
+ /^(Think Harder)$/i,
407
+ /^(Auto|Fast|Expert)$/i, // Model names
408
+ /^Grok\s*\d/i, // "Grok 4.1 Thinking" etc
409
+ /^@\w+$/, // Username mentions alone
410
+ /^[A-Z][a-z]+ \d+$/, // Dates like "Jan 20"
411
+ /^(See new posts|Talk to Grok|Get access to)/, // Sidebar promos
412
+ ];
413
+
414
+ // Normalize prompt for comparison (first 30 chars to handle truncation)
415
+ const promptNorm = userPrompt.toLowerCase().replace(/[^a-z0-9]/g, '').substring(0, 30);
416
+
417
+ // Find the LAST occurrence of the user's question to get the most recent conversation
418
+ let lastQuestionIndex = -1;
419
+ for (let i = lines.length - 1; i >= 0; i--) {
420
+ const lineNorm = lines[i].toLowerCase().replace(/[^a-z0-9]/g, '');
421
+ if (promptNorm && lineNorm.includes(promptNorm)) {
422
+ lastQuestionIndex = i;
423
+ break;
424
+ }
425
+ }
426
+
427
+ // Extract content after the last question
428
+ const contentLines = [];
429
+ const startIndex = lastQuestionIndex >= 0 ? lastQuestionIndex + 1 : 0;
430
+
431
+ for (let i = startIndex; i < lines.length; i++) {
432
+ const line = lines[i];
433
+
434
+ // Skip empty and UI lines
435
+ if (!line || uiPatterns.some(p => p.test(line))) continue;
436
+
437
+ // Skip very short lines that are likely icons/buttons (but keep numbers)
438
+ if (line.length <= 2 && !/^\d+$/.test(line)) continue;
439
+
440
+ // Stop at follow-up suggestions (they mark the end of the response)
441
+ if (/^(Explain|Tell me|Learn more|Show me|Multiplication)/i.test(line)) break;
442
+
443
+ contentLines.push(line);
444
+ }
445
+
446
+ // If we found content after the question, return the response
447
+ if (contentLines.length > 0) {
448
+ return contentLines.join('\n').trim();
449
+ }
450
+
451
+ // Fallback: look for the LAST standalone numeric answer
452
+ for (let i = lines.length - 1; i >= 0; i--) {
453
+ const line = lines[i];
454
+ if (/^\d+\.?\d*$/.test(line)) {
455
+ return line;
456
+ }
457
+ }
458
+
459
+ return null;
460
+ }
461
+
462
+ async function waitForResponse(cdp, timeoutMs = 300000, userPrompt = '') {
463
+ // Grok 4.1 Thinking can take a LONG time (47+ seconds in screenshot)
464
+ // Default to 5 minutes for thinking models
465
+
466
+ const deadline = Date.now() + timeoutMs;
467
+ let previousText = '';
468
+ let stableCycles = 0;
469
+ const requiredStableCycles = 4; // 4 cycles at 300ms = 1.2s stable
470
+ let lastChangeAt = Date.now();
471
+ const minStableMs = 1500; // 1.5 seconds stable
472
+ let thinkingTime = null;
473
+
474
+ // Capture initial page state to detect new content
475
+ const initialSnapshot = await evaluate(cdp, `document.body.innerText.length`);
476
+ const initialLength = initialSnapshot || 0;
477
+
478
+ while (Date.now() < deadline) {
479
+ // Simple approach: get full body text and parse in Node.js
480
+ const snapshot = await evaluate(cdp, `({
481
+ bodyText: document.body.innerText || '',
482
+ hasStopBtn: !!document.querySelector('button[aria-label*="Stop"], button[aria-label*="stop"]'),
483
+ url: location.href
484
+ })`);
485
+
486
+ if (!snapshot || !snapshot.bodyText) {
487
+ await delay(300);
488
+ continue;
489
+ }
490
+
491
+ const bodyText = snapshot.bodyText;
492
+
493
+ // Parse thinking time from body text
494
+ const thinkMatch = bodyText.match(/Thought for (\d+)s/i);
495
+ if (thinkMatch) {
496
+ const t = parseInt(thinkMatch[1], 10);
497
+ if (!thinkingTime || t > thinkingTime) thinkingTime = t;
498
+ }
499
+
500
+ // Check if still actively thinking
501
+ const isThinking = /\bthinking\.{0,3}$/im.test(bodyText);
502
+
503
+ // Check for completion indicators in body text
504
+ // Grok shows follow-up suggestions when done
505
+ const hasFollowUps = /Explain|Tell me more|Learn more/i.test(bodyText);
506
+
507
+ // Track body text changes for stability
508
+ if (bodyText.length !== previousText.length) {
509
+ previousText = bodyText;
510
+ stableCycles = 0;
511
+ lastChangeAt = Date.now();
512
+ } else {
513
+ stableCycles++;
514
+ }
515
+
516
+ const stableMs = Date.now() - lastChangeAt;
517
+ const isStable = stableCycles >= requiredStableCycles && stableMs >= minStableMs;
518
+
519
+ // Response is complete when:
520
+ // 1. Not generating (no stop button)
521
+ // 2. Not actively thinking
522
+ // 3. Has follow-up suggestions OR body text is stable
523
+ // 4. Body is longer than initial (new content appeared)
524
+ const isDone = !snapshot.hasStopBtn && !isThinking &&
525
+ (hasFollowUps || isStable) &&
526
+ bodyText.length > initialLength;
527
+
528
+ if (isDone) {
529
+ // Extract the response from the body text
530
+ // The response is typically between the user's question and the follow-up suggestions
531
+ const responseText = extractGrokResponse(bodyText, userPrompt);
532
+
533
+ if (responseText) {
534
+ return {
535
+ text: responseText,
536
+ thinkingTime: thinkingTime,
537
+ url: snapshot.url,
538
+ };
539
+ }
540
+ }
541
+
542
+ await delay(300);
543
+ }
544
+
545
+ // Timeout - return whatever we have
546
+ const finalText = extractGrokResponse(previousText, userPrompt);
547
+ if (finalText) {
548
+ return {
549
+ text: finalText,
550
+ thinkingTime: thinkingTime,
551
+ partial: true,
552
+ };
553
+ }
554
+
555
+ throw new Error("Response timeout - Grok did not complete in time");
556
+ }
557
+
558
+ // ============================================================================
559
+ // Main Query Function
560
+ // ============================================================================
561
+
562
+ async function query(options) {
563
+ const {
564
+ prompt,
565
+ model,
566
+ deepSearch = false,
567
+ timeout = 300000, // 5 minutes default (Grok Thinking is slow)
568
+ getCookies,
569
+ createTab,
570
+ closeTab,
571
+ cdpEvaluate,
572
+ cdpCommand,
573
+ log = () => {},
574
+ } = options;
575
+
576
+ const startTime = Date.now();
577
+ log("Starting Grok query");
578
+
579
+ // Check cookies for X.com authentication
580
+ const { cookies } = await getCookies();
581
+ if (!hasRequiredCookies(cookies)) {
582
+ throw new Error("X.com login required - log in to x.com in Chrome first");
583
+ }
584
+ log(`Got ${cookies.length} cookies`);
585
+
586
+ // Create tab
587
+ const tabInfo = await createTab();
588
+ const { tabId } = tabInfo || {};
589
+
590
+ if (!tabId) {
591
+ throw new Error(`Failed to create Grok tab: ${JSON.stringify(tabInfo)}`);
592
+ }
593
+ log(`Created tab ${tabId}`);
594
+
595
+ const cdp = (expr) => cdpEvaluate(tabId, expr);
596
+ const inputCdp = (method, params) => cdpCommand(tabId, method, params);
597
+
598
+ try {
599
+ // Wait for page load
600
+ await waitForPageLoad(cdp);
601
+ log("Page loaded");
602
+
603
+ // Check login status
604
+ const loginStatus = await checkLoginStatus(cdp);
605
+ if (!loginStatus.loggedIn) {
606
+ throw new Error("X.com login required - log in to x.com in Chrome first");
607
+ }
608
+ if (!loginStatus.hasPremium) {
609
+ log("Warning: X Premium may be required for some Grok features");
610
+ }
611
+ log(`Login: yes${loginStatus.hasPremium ? ' (Premium)' : ''}`);
612
+
613
+ // Track warnings for agent feedback
614
+ const warnings = [];
615
+
616
+ // Wait for Grok UI
617
+ await waitForGrokReady(cdp);
618
+ log("Grok ready");
619
+
620
+ // Select model (use default if not specified)
621
+ const targetModel = model || DEFAULT_MODEL;
622
+ let selectedModel = targetModel;
623
+ let modelSelectionFailed = false;
624
+ try {
625
+ selectedModel = await selectModel(cdp, targetModel);
626
+ log(`Model: ${selectedModel}`);
627
+ // Check if we got a different model than requested
628
+ const requestedNorm = targetModel.toLowerCase().replace(/[^a-z0-9]/g, '');
629
+ const selectedNorm = selectedModel.toLowerCase().replace(/[^a-z0-9]/g, '');
630
+ if (!selectedNorm.includes(requestedNorm) && !requestedNorm.includes(selectedNorm)) {
631
+ warnings.push(`Requested model "${targetModel}" but got "${selectedModel}" - model may not be available`);
632
+ }
633
+ } catch (e) {
634
+ modelSelectionFailed = true;
635
+ warnings.push(`Model selection failed: ${e.message}. Run 'surf grok --validate' to check available models.`);
636
+ log(`Model selection failed: ${e.message}`);
637
+ }
638
+
639
+ // Enable DeepSearch if requested
640
+ let deepSearchEnabled = false;
641
+ if (deepSearch) {
642
+ try {
643
+ const dsResult = await enableDeepSearch(cdp);
644
+ if (dsResult.success) {
645
+ deepSearchEnabled = true;
646
+ log("DeepSearch enabled");
647
+ } else {
648
+ warnings.push(`DeepSearch toggle not found - feature may require X Premium or UI changed`);
649
+ }
650
+ } catch (e) {
651
+ warnings.push(`DeepSearch toggle failed: ${e.message}`);
652
+ log(`DeepSearch toggle failed: ${e.message}`);
653
+ }
654
+ }
655
+
656
+ // Type prompt
657
+ await typePrompt(cdp, inputCdp, prompt);
658
+ log("Prompt typed");
659
+
660
+ // Submit
661
+ await submitPrompt(cdp, inputCdp);
662
+ log("Submitted, waiting for response...");
663
+
664
+ // Wait for response
665
+ const response = await waitForResponse(cdp, timeout, prompt);
666
+ const thinkingInfo = response.thinkingTime ? ` (thought for ${response.thinkingTime}s)` : '';
667
+ log(`Response: ${response.text.length} chars${thinkingInfo}${response.partial ? ' (partial)' : ''}`);
668
+
669
+ return {
670
+ response: response.text,
671
+ model: selectedModel,
672
+ requestedModel: targetModel,
673
+ modelSelectionFailed,
674
+ thinkingTime: response.thinkingTime,
675
+ deepSearch: deepSearch,
676
+ deepSearchEnabled,
677
+ url: response.url,
678
+ partial: response.partial || false,
679
+ warnings: warnings.length > 0 ? warnings : undefined,
680
+ tookMs: Date.now() - startTime,
681
+ };
682
+ } finally {
683
+ await closeTab(tabId).catch(() => {});
684
+ }
685
+ }
686
+
687
+ // ============================================================================
688
+ // Validate Function - Check UI structure and scrape available models
689
+ // ============================================================================
690
+
691
+ async function validate(options) {
692
+ const {
693
+ getCookies,
694
+ createTab,
695
+ closeTab,
696
+ cdpEvaluate,
697
+ log = () => {},
698
+ } = options;
699
+
700
+ const startTime = Date.now();
701
+ log("Starting Grok validation");
702
+
703
+ const result = {
704
+ authenticated: false,
705
+ premium: false,
706
+ models: [],
707
+ expectedModels: Object.keys(getGrokModels()),
708
+ modelMismatch: false,
709
+ inputFound: false,
710
+ sendButtonFound: false,
711
+ errors: [],
712
+ configPath: getConfigPath() || "~/surf.json",
713
+ };
714
+
715
+ // Check cookies
716
+ try {
717
+ const { cookies } = await getCookies();
718
+ result.authenticated = hasRequiredCookies(cookies);
719
+ if (!result.authenticated) {
720
+ result.errors.push("Not authenticated - log in to x.com in Chrome first");
721
+ return { ...result, tookMs: Date.now() - startTime };
722
+ }
723
+ log("Cookies OK");
724
+ } catch (e) {
725
+ result.errors.push(`Cookie check failed: ${e.message}`);
726
+ return { ...result, tookMs: Date.now() - startTime };
727
+ }
728
+
729
+ // Create tab
730
+ let tabId;
731
+ try {
732
+ const tabInfo = await createTab();
733
+ tabId = tabInfo?.tabId;
734
+ if (!tabId) {
735
+ result.errors.push("Failed to create tab");
736
+ return { ...result, tookMs: Date.now() - startTime };
737
+ }
738
+ log(`Created tab ${tabId}`);
739
+ } catch (e) {
740
+ result.errors.push(`Tab creation failed: ${e.message}`);
741
+ return { ...result, tookMs: Date.now() - startTime };
742
+ }
743
+
744
+ const cdp = (expr) => cdpEvaluate(tabId, expr);
745
+
746
+ try {
747
+ // Wait for page load
748
+ await waitForPageLoad(cdp);
749
+ log("Page loaded");
750
+
751
+ // Check login status
752
+ const loginStatus = await checkLoginStatus(cdp);
753
+ result.authenticated = loginStatus.loggedIn;
754
+ result.premium = loginStatus.hasPremium;
755
+
756
+ if (!loginStatus.loggedIn) {
757
+ result.errors.push("Page shows logged out state");
758
+ return { ...result, tookMs: Date.now() - startTime };
759
+ }
760
+ log(`Login: yes${result.premium ? ' (Premium)' : ''}`);
761
+
762
+ // Wait for Grok UI
763
+ await waitForGrokReady(cdp);
764
+ log("Grok ready");
765
+
766
+ // Check for input field
767
+ const inputCheck = await evaluate(cdp, `(() => {
768
+ const input = document.querySelector('textarea, [contenteditable="true"][role="textbox"], [data-testid="grokComposerInput"]');
769
+ return { found: !!input && input.offsetParent !== null };
770
+ })()`);
771
+ result.inputFound = inputCheck?.found || false;
772
+ log(`Input field: ${result.inputFound ? 'found' : 'NOT FOUND'}`);
773
+
774
+ // Check for send button
775
+ const sendCheck = await evaluate(cdp, `(() => {
776
+ const buttons = Array.from(document.querySelectorAll('button'));
777
+ const sendBtn = buttons.find(b => {
778
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
779
+ const testId = b.getAttribute('data-testid') || '';
780
+ return label.includes('send') || testId.includes('send') || testId.includes('submit');
781
+ });
782
+ return { found: !!sendBtn };
783
+ })()`);
784
+ result.sendButtonFound = sendCheck?.found || false;
785
+ log(`Send button: ${result.sendButtonFound ? 'found' : 'NOT FOUND'}`);
786
+
787
+ // Click model selector and scrape models
788
+ const modelButtonClicked = await evaluate(cdp, `(() => {
789
+ ${buildClickDispatcher()}
790
+ const buttons = Array.from(document.querySelectorAll('button'));
791
+ const modelBtn = buttons.find(b => {
792
+ const text = (b.textContent || '').toLowerCase();
793
+ const label = (b.getAttribute('aria-label') || '').toLowerCase();
794
+ const testId = (b.getAttribute('data-testid') || '').toLowerCase();
795
+ const hasModelName = /^(auto|fast|expert|grok\\s*4)/i.test(text.trim());
796
+ const hasModelLabel = label.includes('model') || testId.includes('model');
797
+ return hasModelName || hasModelLabel;
798
+ });
799
+ if (!modelBtn) return { success: false };
800
+ dispatchClickSequence(modelBtn);
801
+ return { success: true };
802
+ })()`);
803
+
804
+ if (modelButtonClicked?.success) {
805
+ await delay(500);
806
+
807
+ // Scrape model options
808
+ const modelScrape = await evaluate(cdp, `(() => {
809
+ const items = document.querySelectorAll('[role="menuitem"], [role="menuitemradio"], [role="option"]');
810
+ const models = [];
811
+ for (const item of items) {
812
+ const text = (item.textContent || '').trim();
813
+ // Skip non-model items like "Go to grok.com"
814
+ if (text && !text.toLowerCase().includes('go to') && !text.toLowerCase().includes('grok.com')) {
815
+ // Extract just the model name (first line if multi-line)
816
+ const name = text.split('\\n')[0].trim();
817
+ if (name) models.push(name);
818
+ }
819
+ }
820
+ return { models };
821
+ })()`);
822
+
823
+ result.models = modelScrape?.models || [];
824
+ log(`Found models: ${result.models.join(', ')}`);
825
+
826
+ // Close the menu
827
+ await evaluate(cdp, `document.body.click()`);
828
+ } else {
829
+ log("Could not open model selector");
830
+ result.errors.push("Model selector button not found");
831
+ }
832
+
833
+ // Check for model mismatch
834
+ const expectedNames = Object.values(getGrokModels()).map(m => m.name.toLowerCase());
835
+ const foundNames = result.models.map(m => m.toLowerCase());
836
+
837
+ const missing = expectedNames.filter(e => !foundNames.some(f => f.includes(e) || e.includes(f)));
838
+ const extra = foundNames.filter(f => !expectedNames.some(e => f.includes(e) || e.includes(f)));
839
+
840
+ if (missing.length > 0 || extra.length > 0) {
841
+ result.modelMismatch = true;
842
+ if (missing.length > 0) {
843
+ result.errors.push(`Expected models not found: ${missing.join(', ')}`);
844
+ }
845
+ if (extra.length > 0) {
846
+ result.errors.push(`Unexpected models found: ${extra.join(', ')}`);
847
+ }
848
+ }
849
+
850
+ } catch (e) {
851
+ result.errors.push(`Validation error: ${e.message}`);
852
+ } finally {
853
+ await closeTab(tabId).catch(() => {});
854
+ }
855
+
856
+ result.tookMs = Date.now() - startTime;
857
+ return result;
858
+ }
859
+
860
+ // Save discovered models to surf.json config
861
+ function saveModels(models) {
862
+ const fs = require("fs");
863
+ const path = require("path");
864
+ const os = require("os");
865
+
866
+ try {
867
+ // Use existing config path or default to ~/surf.json
868
+ let configPath = getConfigPath();
869
+ if (!configPath) {
870
+ configPath = path.join(os.homedir(), "surf.json");
871
+ }
872
+
873
+ // Load existing config or start fresh
874
+ let config = {};
875
+ if (fs.existsSync(configPath)) {
876
+ try {
877
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
878
+ } catch (e) {
879
+ // Start fresh if parse fails
880
+ }
881
+ }
882
+
883
+ // Update grok.models
884
+ config.grok = config.grok || {};
885
+ config.grok.models = models;
886
+
887
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
888
+ clearCache(); // Clear config cache so subsequent reads see new values
889
+ return { success: true, path: configPath };
890
+ } catch (e) {
891
+ return { success: false, error: e.message };
892
+ }
893
+ }
894
+
895
+ module.exports = {
896
+ query,
897
+ validate,
898
+ hasRequiredCookies,
899
+ getGrokModels,
900
+ saveModels,
901
+ extractGrokResponse,
902
+ GROK_URL,
903
+ GROK_MODELS,
904
+ DEFAULT_GROK_MODELS,
905
+ DEFAULT_MODEL,
906
+ };