@jackwener/opencli 1.5.9 → 1.6.1

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +18 -0
  3. package/SKILL.md +59 -0
  4. package/autoresearch/baseline-browse.txt +1 -0
  5. package/autoresearch/baseline-skill.txt +1 -0
  6. package/autoresearch/browse-tasks.json +688 -0
  7. package/autoresearch/eval-browse.ts +185 -0
  8. package/autoresearch/eval-skill.ts +248 -0
  9. package/autoresearch/run-browse.sh +9 -0
  10. package/autoresearch/run-skill.sh +9 -0
  11. package/bun.lock +615 -0
  12. package/dist/browser/daemon-client.d.ts +20 -1
  13. package/dist/browser/daemon-client.js +37 -30
  14. package/dist/browser/daemon-client.test.d.ts +1 -0
  15. package/dist/browser/daemon-client.test.js +77 -0
  16. package/dist/browser/discover.js +8 -19
  17. package/dist/browser/page.d.ts +4 -0
  18. package/dist/browser/page.js +48 -1
  19. package/dist/cli-manifest.json +2 -2
  20. package/dist/cli.js +392 -0
  21. package/dist/clis/twitter/article.js +28 -1
  22. package/dist/clis/twitter/search.js +67 -5
  23. package/dist/clis/twitter/search.test.js +83 -5
  24. package/dist/clis/xiaohongshu/note.js +11 -0
  25. package/dist/clis/xiaohongshu/note.test.js +49 -0
  26. package/dist/commanderAdapter.js +1 -1
  27. package/dist/commanderAdapter.test.js +43 -0
  28. package/dist/commands/daemon.js +7 -46
  29. package/dist/commands/daemon.test.js +44 -69
  30. package/dist/discovery.js +27 -0
  31. package/dist/types.d.ts +8 -0
  32. package/docs/guide/getting-started.md +21 -0
  33. package/docs/superpowers/specs/2026-04-02-browse-skill-testing-design.md +144 -0
  34. package/docs/zh/guide/getting-started.md +21 -0
  35. package/extension/package-lock.json +2 -2
  36. package/extension/src/background.ts +51 -4
  37. package/extension/src/cdp.ts +77 -124
  38. package/extension/src/protocol.ts +5 -1
  39. package/package.json +1 -1
  40. package/skills/opencli-explorer/SKILL.md +6 -0
  41. package/skills/opencli-oneshot/SKILL.md +6 -0
  42. package/skills/opencli-operate/SKILL.md +213 -0
  43. package/skills/opencli-usage/SKILL.md +113 -32
  44. package/src/browser/daemon-client.test.ts +103 -0
  45. package/src/browser/daemon-client.ts +53 -30
  46. package/src/browser/discover.ts +8 -17
  47. package/src/browser/page.ts +48 -1
  48. package/src/cli.ts +392 -0
  49. package/src/clis/twitter/article.ts +31 -1
  50. package/src/clis/twitter/search.test.ts +88 -5
  51. package/src/clis/twitter/search.ts +68 -5
  52. package/src/clis/xiaohongshu/note.test.ts +51 -0
  53. package/src/clis/xiaohongshu/note.ts +18 -0
  54. package/src/commanderAdapter.test.ts +62 -0
  55. package/src/commanderAdapter.ts +1 -1
  56. package/src/commands/daemon.test.ts +49 -83
  57. package/src/commands/daemon.ts +7 -55
  58. package/src/discovery.ts +22 -0
  59. package/src/doctor.ts +1 -1
  60. package/src/types.ts +8 -0
  61. package/extension/dist/background.js +0 -681
package/src/cli.ts CHANGED
@@ -18,6 +18,13 @@ import { registerAllCommands } from './commanderAdapter.js';
18
18
  import { EXIT_CODES, getErrorMessage } from './errors.js';
19
19
  import { daemonStatus, daemonStop, daemonRestart } from './commands/daemon.js';
20
20
 
21
+ /** Create a browser page for operate commands. Uses 'operate' workspace for session persistence. */
22
+ async function getOperatePage(): Promise<import('./types.js').IPage> {
23
+ const { BrowserBridge } = await import('./browser/index.js');
24
+ const bridge = new BrowserBridge();
25
+ return bridge.connect({ timeout: 30, workspace: 'operate:default' });
26
+ }
27
+
21
28
  export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
22
29
  const program = new Command();
23
30
  // enablePositionalOptions: prevents parent from consuming flags meant for subcommands;
@@ -229,6 +236,391 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
229
236
  console.log(renderCascadeResult(result));
230
237
  });
231
238
 
239
+ // ── Built-in: operate (browser control for Claude Code skill) ───────────────
240
+ //
241
+ // Make websites accessible for AI agents.
242
+ // All commands wrapped in operateAction() for consistent error handling.
243
+
244
+ const operate = program
245
+ .command('operate')
246
+ .description('Browser control — navigate, click, type, extract, wait (no LLM needed)');
247
+
248
+ /** Wrap operate actions with error handling and optional --json output */
249
+ function operateAction(fn: (page: Awaited<ReturnType<typeof getOperatePage>>, ...args: any[]) => Promise<unknown>) {
250
+ return async (...args: any[]) => {
251
+ try {
252
+ const page = await getOperatePage();
253
+ await fn(page, ...args);
254
+ } catch (err) {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ if (msg.includes('Extension not connected') || msg.includes('Daemon')) {
257
+ console.error(`Browser not connected. Run 'opencli doctor' to diagnose.`);
258
+ } else if (msg.includes('attach failed') || msg.includes('chrome-extension://')) {
259
+ console.error(`Browser attach failed — another extension may be interfering. Try disabling 1Password.`);
260
+ } else {
261
+ console.error(`Error: ${msg}`);
262
+ }
263
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
264
+ }
265
+ };
266
+ }
267
+
268
+ // ── Navigation ──
269
+
270
+ /** Network interceptor JS — injected on every open/navigate to capture fetch/XHR */
271
+ const NETWORK_INTERCEPTOR_JS = `(function(){if(window.__opencli_net)return;window.__opencli_net=[];var M=200,B=50000,F=window.fetch;window.fetch=async function(){var r=await F.apply(this,arguments);try{var ct=r.headers.get('content-type')||'';if(ct.includes('json')||ct.includes('text')){var c=r.clone(),t=await c.text();if(window.__opencli_net.length<M){var b=null;if(t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:r.url||(arguments[0]&&arguments[0].url)||String(arguments[0]),method:(arguments[1]&&arguments[1].method)||'GET',status:r.status,size:t.length,ct:ct,body:b})}}}catch(e){}return r};var X=XMLHttpRequest.prototype,O=X.open,S=X.send;X.open=function(m,u){this._om=m;this._ou=u;return O.apply(this,arguments)};X.send=function(){var x=this;x.addEventListener('load',function(){try{var ct=x.getResponseHeader('content-type')||'';if((ct.includes('json')||ct.includes('text'))&&window.__opencli_net.length<M){var t=x.responseText,b=null;if(t&&t.length<=B)try{b=JSON.parse(t)}catch(e){b=t}window.__opencli_net.push({url:x._ou,method:x._om||'GET',status:x.status,size:t?t.length:0,ct:ct,body:b})}}catch(e){}});return S.apply(this,arguments)}})()`;
272
+
273
+ operate.command('open').argument('<url>').description('Open URL in automation window')
274
+ .action(operateAction(async (page, url) => {
275
+ await page.goto(url);
276
+ await page.wait(2);
277
+ // Auto-inject network interceptor for API discovery
278
+ try { await page.evaluate(NETWORK_INTERCEPTOR_JS); } catch { /* non-fatal */ }
279
+ console.log(`Navigated to: ${await page.getCurrentUrl?.() ?? url}`);
280
+ }));
281
+
282
+ operate.command('back').description('Go back in browser history')
283
+ .action(operateAction(async (page) => {
284
+ await page.evaluate('history.back()');
285
+ await page.wait(2);
286
+ console.log('Navigated back');
287
+ }));
288
+
289
+ operate.command('scroll').argument('<direction>', 'up or down').option('--amount <pixels>', 'Pixels to scroll', '500')
290
+ .description('Scroll page')
291
+ .action(operateAction(async (page, direction, opts) => {
292
+ if (direction !== 'up' && direction !== 'down') {
293
+ console.error(`Invalid direction "${direction}". Use "up" or "down".`);
294
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
295
+ return;
296
+ }
297
+ await page.scroll(direction, parseInt(opts.amount, 10));
298
+ console.log(`Scrolled ${direction}`);
299
+ }));
300
+
301
+ // ── Inspect ──
302
+
303
+ operate.command('state').description('Page state: URL, title, interactive elements with [N] indices')
304
+ .action(operateAction(async (page) => {
305
+ const snapshot = await page.snapshot({ viewportExpand: 800 });
306
+ const url = await page.getCurrentUrl?.() ?? '';
307
+ console.log(`URL: ${url}\n`);
308
+ console.log(typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2));
309
+ }));
310
+
311
+ operate.command('screenshot').argument('[path]', 'Save to file (base64 if omitted)')
312
+ .description('Take screenshot')
313
+ .action(operateAction(async (page, path) => {
314
+ if (path) {
315
+ await page.screenshot({ path });
316
+ console.log(`Screenshot saved to: ${path}`);
317
+ } else {
318
+ console.log(await page.screenshot({ format: 'png' }));
319
+ }
320
+ }));
321
+
322
+ // ── Get commands (structured data extraction) ──
323
+
324
+ const get = operate.command('get').description('Get page properties');
325
+
326
+ get.command('title').description('Page title')
327
+ .action(operateAction(async (page) => {
328
+ console.log(await page.evaluate('document.title'));
329
+ }));
330
+
331
+ get.command('url').description('Current page URL')
332
+ .action(operateAction(async (page) => {
333
+ console.log(await page.getCurrentUrl?.() ?? await page.evaluate('location.href'));
334
+ }));
335
+
336
+ get.command('text').argument('<index>', 'Element index').description('Element text content')
337
+ .action(operateAction(async (page, index) => {
338
+ const text = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.textContent?.trim()`);
339
+ console.log(text ?? '(empty)');
340
+ }));
341
+
342
+ get.command('value').argument('<index>', 'Element index').description('Input/textarea value')
343
+ .action(operateAction(async (page, index) => {
344
+ const val = await page.evaluate(`document.querySelector('[data-opencli-ref="${index}"]')?.value`);
345
+ console.log(val ?? '(empty)');
346
+ }));
347
+
348
+ get.command('html').option('--selector <css>', 'CSS selector scope').description('Page HTML (or scoped)')
349
+ .action(operateAction(async (page, opts) => {
350
+ const sel = opts.selector ? JSON.stringify(opts.selector) : 'null';
351
+ const html = await page.evaluate(`(${sel} ? document.querySelector(${sel})?.outerHTML : document.documentElement.outerHTML)?.slice(0, 50000)`);
352
+ console.log(html ?? '(empty)');
353
+ }));
354
+
355
+ get.command('attributes').argument('<index>', 'Element index').description('Element attributes')
356
+ .action(operateAction(async (page, index) => {
357
+ const attrs = await page.evaluate(`JSON.stringify(Object.fromEntries([...document.querySelector('[data-opencli-ref="${index}"]')?.attributes].map(a=>[a.name,a.value])))`);
358
+ console.log(attrs ?? '{}');
359
+ }));
360
+
361
+ // ── Interact ──
362
+
363
+ operate.command('click').argument('<index>', 'Element index from state').description('Click element by index')
364
+ .action(operateAction(async (page, index) => {
365
+ await page.click(index);
366
+ console.log(`Clicked element [${index}]`);
367
+ }));
368
+
369
+ operate.command('type').argument('<index>', 'Element index').argument('<text>', 'Text to type')
370
+ .description('Click element, then type text')
371
+ .action(operateAction(async (page, index, text) => {
372
+ await page.click(index);
373
+ await page.wait(0.3);
374
+ await page.typeText(index, text);
375
+ console.log(`Typed "${text}" into element [${index}]`);
376
+ }));
377
+
378
+ operate.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
379
+ .description('Select dropdown option')
380
+ .action(operateAction(async (page, index, option) => {
381
+ const result = await page.evaluate(`
382
+ (function() {
383
+ var sel = document.querySelector('[data-opencli-ref="${index}"]');
384
+ if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
385
+ var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
386
+ if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };
387
+ var setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
388
+ if (setter) setter.call(sel, match.value); else sel.value = match.value;
389
+ sel.dispatchEvent(new Event('input', {bubbles:true}));
390
+ sel.dispatchEvent(new Event('change', {bubbles:true}));
391
+ return { selected: match.text };
392
+ })()
393
+ `) as { error?: string; selected?: string; available?: string[] } | null;
394
+ if (result?.error) {
395
+ console.error(`Error: ${result.error}${result.available ? ` — Available: ${result.available.join(', ')}` : ''}`);
396
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
397
+ } else {
398
+ console.log(`Selected "${result?.selected}" in element [${index}]`);
399
+ }
400
+ }));
401
+
402
+ operate.command('keys').argument('<key>', 'Key to press (Enter, Escape, Tab, Control+a)')
403
+ .description('Press keyboard key')
404
+ .action(operateAction(async (page, key) => {
405
+ await page.pressKey(key);
406
+ console.log(`Pressed: ${key}`);
407
+ }));
408
+
409
+ // ── Wait commands ──
410
+
411
+ operate.command('wait')
412
+ .argument('<type>', 'selector, text, or time')
413
+ .argument('[value]', 'CSS selector, text string, or seconds')
414
+ .option('--timeout <ms>', 'Timeout in milliseconds', '10000')
415
+ .description('Wait for selector, text, or time (e.g. wait selector ".loaded", wait text "Success", wait time 3)')
416
+ .action(operateAction(async (page, type, value, opts) => {
417
+ const timeout = parseInt(opts.timeout, 10);
418
+ if (type === 'time') {
419
+ const seconds = parseFloat(value ?? '2');
420
+ await page.wait(seconds);
421
+ console.log(`Waited ${seconds}s`);
422
+ } else if (type === 'selector') {
423
+ if (!value) { console.error('Missing CSS selector'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
424
+ await page.wait({ selector: value, timeout: timeout / 1000 });
425
+ console.log(`Element "${value}" appeared`);
426
+ } else if (type === 'text') {
427
+ if (!value) { console.error('Missing text'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
428
+ await page.wait({ text: value, timeout: timeout / 1000 });
429
+ console.log(`Text "${value}" appeared`);
430
+ } else {
431
+ console.error(`Unknown wait type "${type}". Use: selector, text, or time`);
432
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
433
+ }
434
+ }));
435
+
436
+ // ── Extract ──
437
+
438
+ operate.command('eval').argument('<js>', 'JavaScript code').description('Execute JS in page context, return result')
439
+ .action(operateAction(async (page, js) => {
440
+ const result = await page.evaluate(js);
441
+ if (typeof result === 'string') console.log(result);
442
+ else console.log(JSON.stringify(result, null, 2));
443
+ }));
444
+
445
+ // ── Network (API discovery) ──
446
+
447
+ operate.command('network')
448
+ .option('--detail <index>', 'Show full response body of request at index')
449
+ .option('--all', 'Show all requests including static resources')
450
+ .description('Show captured network requests (auto-captured since last open)')
451
+ .action(operateAction(async (page, opts) => {
452
+ const requests = await page.evaluate(`(function(){
453
+ var reqs = window.__opencli_net || [];
454
+ return JSON.stringify(reqs);
455
+ })()`) as string;
456
+
457
+ let items: Array<{ url: string; method: string; status: number; size: number; ct: string; body: unknown }> = [];
458
+ try { items = JSON.parse(requests); } catch { console.log('No network data captured. Run "operate open <url>" first.'); return; }
459
+
460
+ if (items.length === 0) { console.log('No requests captured.'); return; }
461
+
462
+ // Filter out static resources unless --all
463
+ if (!opts.all) {
464
+ items = items.filter(r =>
465
+ (r.ct?.includes('json') || r.ct?.includes('xml') || r.ct?.includes('text/plain')) &&
466
+ !/\.(js|css|png|jpg|gif|svg|woff|ico|map)(\?|$)/i.test(r.url) &&
467
+ !/analytics|tracking|telemetry|beacon|pixel|gtag|fbevents/i.test(r.url)
468
+ );
469
+ }
470
+
471
+ if (opts.detail !== undefined) {
472
+ const idx = parseInt(opts.detail, 10);
473
+ const req = items[idx];
474
+ if (!req) { console.error(`Request #${idx} not found. ${items.length} requests available.`); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
475
+ console.log(`${req.method} ${req.url}`);
476
+ console.log(`Status: ${req.status} | Size: ${req.size} | Type: ${req.ct}`);
477
+ console.log('---');
478
+ console.log(typeof req.body === 'string' ? req.body : JSON.stringify(req.body, null, 2));
479
+ } else {
480
+ console.log(`Captured ${items.length} API requests:\n`);
481
+ items.forEach((r, i) => {
482
+ const bodyPreview = r.body ? (typeof r.body === 'string' ? r.body.slice(0, 60) : JSON.stringify(r.body).slice(0, 60)) : '';
483
+ console.log(` [${i}] ${r.method} ${r.status} ${r.url.slice(0, 80)}`);
484
+ if (bodyPreview) console.log(` ${bodyPreview}...`);
485
+ });
486
+ console.log(`\nUse --detail <index> to see full response body.`);
487
+ }
488
+ }));
489
+
490
+ // ── Init (adapter scaffolding) ──
491
+
492
+ operate.command('init')
493
+ .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
494
+ .description('Generate adapter scaffold in ~/.opencli/clis/')
495
+ .action(async (name: string) => {
496
+ try {
497
+ const parts = name.split('/');
498
+ if (parts.length !== 2 || !parts[0] || !parts[1]) {
499
+ console.error('Name must be site/command format (e.g. hn/top)');
500
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
501
+ return;
502
+ }
503
+ const [site, command] = parts;
504
+ if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
505
+ console.error('Name parts must be alphanumeric/dash/underscore only');
506
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
507
+ return;
508
+ }
509
+
510
+ const os = await import('node:os');
511
+ const fs = await import('node:fs');
512
+ const path = await import('node:path');
513
+ const dir = path.join(os.homedir(), '.opencli', 'clis', site);
514
+ const filePath = path.join(dir, `${command}.ts`);
515
+
516
+ if (fs.existsSync(filePath)) {
517
+ console.log(`Adapter already exists: ${filePath}`);
518
+ return;
519
+ }
520
+
521
+ // Try to detect domain from last operate session
522
+ let domain = site;
523
+ try {
524
+ const page = await getOperatePage();
525
+ const url = await page.getCurrentUrl?.();
526
+ if (url) { try { domain = new URL(url).hostname; } catch {} }
527
+ } catch { /* no active session */ }
528
+
529
+ const template = `import { cli, Strategy } from '@jackwener/opencli/registry';
530
+
531
+ cli({
532
+ site: '${site}',
533
+ name: '${command}',
534
+ description: '', // TODO: describe what this command does
535
+ domain: '${domain}',
536
+ strategy: Strategy.PUBLIC, // TODO: PUBLIC (no auth), COOKIE (needs login), UI (DOM interaction)
537
+ browser: false, // TODO: set true if needs browser
538
+ args: [
539
+ { name: 'limit', type: 'int', default: 10, help: 'Number of items' },
540
+ ],
541
+ columns: [], // TODO: field names for table output (e.g. ['title', 'score', 'url'])
542
+ func: async (page, kwargs) => {
543
+ // TODO: implement data fetching
544
+ // Prefer API calls (fetch) over browser automation
545
+ // page is available if browser: true
546
+ return [];
547
+ },
548
+ });
549
+ `;
550
+ fs.mkdirSync(dir, { recursive: true });
551
+ fs.writeFileSync(filePath, template, 'utf-8');
552
+ console.log(`Created: ${filePath}`);
553
+ console.log(`Edit the file to implement your adapter, then run: opencli operate verify ${name}`);
554
+ } catch (err) {
555
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
556
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
557
+ }
558
+ });
559
+
560
+ // ── Verify (test adapter) ──
561
+
562
+ operate.command('verify')
563
+ .argument('<name>', 'Adapter name in site/command format (e.g. hn/top)')
564
+ .description('Execute an adapter and show results')
565
+ .action(async (name: string) => {
566
+ try {
567
+ const parts = name.split('/');
568
+ if (parts.length !== 2) { console.error('Name must be site/command format'); process.exitCode = EXIT_CODES.USAGE_ERROR; return; }
569
+ const [site, command] = parts;
570
+ if (!/^[a-zA-Z0-9_-]+$/.test(site) || !/^[a-zA-Z0-9_-]+$/.test(command)) {
571
+ console.error('Name parts must be alphanumeric/dash/underscore only');
572
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
573
+ return;
574
+ }
575
+
576
+ const { execSync } = await import('node:child_process');
577
+ const os = await import('node:os');
578
+ const path = await import('node:path');
579
+ const filePath = path.join(os.homedir(), '.opencli', 'clis', site, `${command}.ts`);
580
+
581
+ const fs = await import('node:fs');
582
+ if (!fs.existsSync(filePath)) {
583
+ console.error(`Adapter not found: ${filePath}`);
584
+ console.error(`Run "opencli operate init ${name}" to create it.`);
585
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
586
+ return;
587
+ }
588
+
589
+ console.log(`🔍 Verifying ${name}...\n`);
590
+ console.log(` Loading: ${filePath}`);
591
+
592
+ try {
593
+ const output = execSync(`node dist/main.js ${site} ${command} --limit 3`, {
594
+ cwd: path.join(path.dirname(import.meta.url.replace('file://', '')), '..'),
595
+ timeout: 30000,
596
+ encoding: 'utf-8',
597
+ env: process.env,
598
+ stdio: ['pipe', 'pipe', 'pipe'],
599
+ });
600
+ console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
601
+ console.log(output);
602
+ console.log(`\n ✓ Adapter works!`);
603
+ } catch (err: any) {
604
+ console.log(` Executing: opencli ${site} ${command} --limit 3\n`);
605
+ if (err.stdout) console.log(err.stdout);
606
+ if (err.stderr) console.error(err.stderr.slice(0, 500));
607
+ console.log(`\n ✗ Adapter failed. Fix the code and try again.`);
608
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
609
+ }
610
+ } catch (err) {
611
+ console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
612
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
613
+ }
614
+ });
615
+
616
+ // ── Session ──
617
+
618
+ operate.command('close').description('Close the automation window')
619
+ .action(operateAction(async (page) => {
620
+ await page.closeWindow?.();
621
+ console.log('Automation window closed');
622
+ }));
623
+
232
624
  // ── Built-in: doctor / completion ──────────────────────────────────────────
233
625
 
234
626
  program
@@ -16,11 +16,41 @@ cli({
16
16
  ],
17
17
  columns: ['title', 'author', 'content', 'url'],
18
18
  func: async (page, kwargs) => {
19
- // Extract tweet ID from URL if needed
19
+ // Extract tweet ID from URL if needed.
20
+ // Article URLs (x.com/i/article/{articleId}) use a different ID than
21
+ // tweet status URLs — the GraphQL endpoint needs the parent tweet ID.
20
22
  let tweetId = kwargs['tweet-id'];
23
+ const isArticleUrl = /\/article\/\d+/.test(tweetId);
21
24
  const urlMatch = tweetId.match(/\/(?:status|article)\/(\d+)/);
22
25
  if (urlMatch) tweetId = urlMatch[1];
23
26
 
27
+ if (isArticleUrl) {
28
+ // Navigate to the article page and resolve the parent tweet ID from DOM
29
+ await page.goto(`https://x.com/i/article/${tweetId}`);
30
+ await page.wait(3);
31
+ const resolvedId = await page.evaluate(`
32
+ (function() {
33
+ var links = document.querySelectorAll('a[href*="/status/"]');
34
+ for (var i = 0; i < links.length; i++) {
35
+ var m = links[i].href.match(/\\/status\\/(\\d+)/);
36
+ if (m) return m[1];
37
+ }
38
+ var og = document.querySelector('meta[property="og:url"]');
39
+ if (og && og.content) {
40
+ var m2 = og.content.match(/\\/status\\/(\\d+)/);
41
+ if (m2) return m2[1];
42
+ }
43
+ return null;
44
+ })()
45
+ `);
46
+ if (!resolvedId || typeof resolvedId !== 'string') {
47
+ throw new CommandExecutionError(
48
+ `Could not resolve article ${tweetId} to a tweet ID. The article page may not contain a linked tweet.`,
49
+ );
50
+ }
51
+ tweetId = resolvedId;
52
+ }
53
+
24
54
  // Navigate to the tweet page for cookie context
25
55
  await page.goto(`https://x.com/i/status/${tweetId}`);
26
56
  await page.wait(3);
@@ -153,15 +153,98 @@ describe('twitter search command', () => {
153
153
  expect(pushStateCall).toContain('f=top');
154
154
  });
155
155
 
156
+ it('falls back to search input when pushState fails twice', async () => {
157
+ const command = getRegistry().get('twitter/search');
158
+ expect(command?.func).toBeTypeOf('function');
159
+
160
+ const evaluate = vi.fn()
161
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
162
+ .mockResolvedValueOnce('/explore') // pathname check 1 — not /search
163
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
164
+ .mockResolvedValueOnce('/explore') // pathname check 2 — still not /search
165
+ .mockResolvedValueOnce({ ok: true }) // search input fallback succeeds
166
+ .mockResolvedValueOnce('/search'); // pathname check after fallback
167
+
168
+ const page = {
169
+ goto: vi.fn().mockResolvedValue(undefined),
170
+ wait: vi.fn().mockResolvedValue(undefined),
171
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
172
+ evaluate,
173
+ autoScroll: vi.fn().mockResolvedValue(undefined),
174
+ getInterceptedRequests: vi.fn().mockResolvedValue([
175
+ {
176
+ data: {
177
+ search_by_raw_query: {
178
+ search_timeline: {
179
+ timeline: {
180
+ instructions: [
181
+ {
182
+ type: 'TimelineAddEntries',
183
+ entries: [
184
+ {
185
+ entryId: 'tweet-99',
186
+ content: {
187
+ itemContent: {
188
+ tweet_results: {
189
+ result: {
190
+ rest_id: '99',
191
+ legacy: {
192
+ full_text: 'fallback works',
193
+ favorite_count: 3,
194
+ created_at: 'Wed Apr 02 12:00:00 +0000 2026',
195
+ },
196
+ core: {
197
+ user_results: {
198
+ result: {
199
+ core: { screen_name: 'bob' },
200
+ },
201
+ },
202
+ },
203
+ views: { count: '5' },
204
+ },
205
+ },
206
+ },
207
+ },
208
+ },
209
+ ],
210
+ },
211
+ ],
212
+ },
213
+ },
214
+ },
215
+ },
216
+ },
217
+ ]),
218
+ };
219
+
220
+ const result = await command!.func!(page as any, { query: 'test fallback', filter: 'top', limit: 5 });
221
+
222
+ expect(result).toEqual([
223
+ {
224
+ id: '99',
225
+ author: 'bob',
226
+ text: 'fallback works',
227
+ created_at: 'Wed Apr 02 12:00:00 +0000 2026',
228
+ likes: 3,
229
+ views: '5',
230
+ url: 'https://x.com/i/status/99',
231
+ },
232
+ ]);
233
+ // 6 evaluate calls: 2x pushState + 2x pathname check + 1x fallback + 1x pathname check
234
+ expect(evaluate).toHaveBeenCalledTimes(6);
235
+ expect(page.autoScroll).toHaveBeenCalled();
236
+ });
237
+
156
238
  it('throws with the final path after both attempts fail', async () => {
157
239
  const command = getRegistry().get('twitter/search');
158
240
  expect(command?.func).toBeTypeOf('function');
159
241
 
160
242
  const evaluate = vi.fn()
161
- .mockResolvedValueOnce(undefined)
162
- .mockResolvedValueOnce('/explore')
163
- .mockResolvedValueOnce(undefined)
164
- .mockResolvedValueOnce('/login');
243
+ .mockResolvedValueOnce(undefined) // pushState attempt 1
244
+ .mockResolvedValueOnce('/explore') // pathname check 1
245
+ .mockResolvedValueOnce(undefined) // pushState attempt 2
246
+ .mockResolvedValueOnce('/login') // pathname check 2
247
+ .mockResolvedValueOnce({ ok: false }); // search input fallback
165
248
 
166
249
  const page = {
167
250
  goto: vi.fn().mockResolvedValue(undefined),
@@ -177,6 +260,6 @@ describe('twitter search command', () => {
177
260
  .toThrow('Final path: /login');
178
261
  expect(page.autoScroll).not.toHaveBeenCalled();
179
262
  expect(page.getInterceptedRequests).not.toHaveBeenCalled();
180
- expect(evaluate).toHaveBeenCalledTimes(4);
263
+ expect(evaluate).toHaveBeenCalledTimes(5);
181
264
  });
182
265
  });
@@ -3,16 +3,19 @@ import { cli, Strategy } from '../../registry.js';
3
3
  import type { IPage } from '../../types.js';
4
4
 
5
5
  /**
6
- * Trigger Twitter search SPA navigation and retry once on transient failures.
6
+ * Trigger Twitter search SPA navigation with fallback strategies.
7
7
  *
8
- * Twitter/X sometimes keeps the page on /explore for a short period even after
9
- * pushState + popstate. A second attempt is enough for the intermittent cases
10
- * reported in issue #353 while keeping the flow narrowly scoped.
8
+ * Primary: pushState + popstate (works in most environments).
9
+ * Fallback: Type into the search input and press Enter when pushState fails
10
+ * intermittently (e.g. due to Twitter A/B tests or timing races — see #690).
11
+ *
12
+ * Both strategies preserve the JS context so the fetch interceptor stays alive.
11
13
  */
12
14
  async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: string, filter: string): Promise<void> {
13
15
  const searchUrl = JSON.stringify(`/search?q=${encodeURIComponent(query)}&f=${filter}`);
14
16
  let lastPath = '';
15
17
 
18
+ // Strategy 1 (primary): pushState + popstate with retry
16
19
  for (let attempt = 1; attempt <= 2; attempt++) {
17
20
  await page.evaluate(`
18
21
  (() => {
@@ -20,7 +23,12 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
20
23
  window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
21
24
  })()
22
25
  `);
23
- await page.wait({ selector: '[data-testid="primaryColumn"]' });
26
+
27
+ try {
28
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
29
+ } catch {
30
+ // selector timeout — fall through to path check or next attempt
31
+ }
24
32
 
25
33
  lastPath = String(await page.evaluate('() => window.location.pathname') || '');
26
34
  if (lastPath.startsWith('/search')) {
@@ -32,6 +40,61 @@ async function navigateToSearch(page: Pick<IPage, 'evaluate' | 'wait'>, query: s
32
40
  }
33
41
  }
34
42
 
43
+ // Strategy 2 (fallback): Use the search input on /explore.
44
+ // The nativeSetter + Enter approach triggers Twitter's own form handler,
45
+ // performing SPA navigation without a full page reload.
46
+ const queryStr = JSON.stringify(query);
47
+ const navResult = await page.evaluate(`(async () => {
48
+ try {
49
+ const input = document.querySelector('[data-testid="SearchBox_Search_Input"]');
50
+ if (!input) return { ok: false };
51
+
52
+ input.focus();
53
+ await new Promise(r => setTimeout(r, 300));
54
+
55
+ const nativeSetter = Object.getOwnPropertyDescriptor(
56
+ window.HTMLInputElement.prototype, 'value'
57
+ )?.set;
58
+ if (!nativeSetter) return { ok: false };
59
+ nativeSetter.call(input, ${queryStr});
60
+ input.dispatchEvent(new Event('input', { bubbles: true }));
61
+ input.dispatchEvent(new Event('change', { bubbles: true }));
62
+ await new Promise(r => setTimeout(r, 500));
63
+
64
+ input.dispatchEvent(new KeyboardEvent('keydown', {
65
+ key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true
66
+ }));
67
+
68
+ return { ok: true };
69
+ } catch {
70
+ return { ok: false };
71
+ }
72
+ })()`);
73
+
74
+ if (navResult?.ok) {
75
+ try {
76
+ await page.wait({ selector: '[data-testid="primaryColumn"]' });
77
+ } catch {
78
+ // fall through to path check
79
+ }
80
+ lastPath = String(await page.evaluate('() => window.location.pathname') || '');
81
+ if (lastPath.startsWith('/search')) {
82
+ if (filter === 'live') {
83
+ await page.evaluate(`(() => {
84
+ const tabs = document.querySelectorAll('[role="tab"]');
85
+ for (const tab of tabs) {
86
+ if (tab.textContent.includes('Latest') || tab.textContent.includes('最新')) {
87
+ tab.click();
88
+ return;
89
+ }
90
+ }
91
+ })()`);
92
+ await page.wait(2);
93
+ }
94
+ return;
95
+ }
96
+ }
97
+
35
98
  throw new CommandExecutionError(
36
99
  `SPA navigation to /search failed. Final path: ${lastPath || '(empty)'}. Twitter may have changed its routing.`,
37
100
  );