From 1aaafed2dbe47dd9b659af3b6594a92a459e5efd Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 15 Mar 2026 21:42:48 +0800 Subject: [PATCH] feat(twitter): implement post, reply, delete, following, followers and notifications adapters --- README.md | 4 +- src/browser.test.ts | 51 ++++ src/browser.ts | 176 ++++++++++++- src/clis/twitter/delete.ts | 78 ++++++ src/clis/twitter/followers.ts | 119 +++++++++ src/clis/twitter/following.ts | 105 ++++++++ src/clis/twitter/notifications.ts | 119 +++++++++ src/clis/twitter/post.ts | 68 +++++ src/clis/twitter/reply.ts | 62 +++++ src/doctor.test.ts | 133 ++++++++++ src/doctor.ts | 421 ++++++++++++++++++++++++++++++ src/main.ts | 24 ++ 12 files changed, 1346 insertions(+), 14 deletions(-) create mode 100644 src/browser.test.ts create mode 100644 src/clis/twitter/delete.ts create mode 100644 src/clis/twitter/followers.ts create mode 100644 src/clis/twitter/following.ts create mode 100644 src/clis/twitter/notifications.ts create mode 100644 src/clis/twitter/post.ts create mode 100644 src/clis/twitter/reply.ts create mode 100644 src/doctor.test.ts create mode 100644 src/doctor.ts diff --git a/README.md b/README.md index e9c3904..4c816e4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Node.js Version](https://img.shields.io/node/v/@jackwener/opencli?style=flat-square)](https://nodejs.org) [![License](https://img.shields.io/npm/l/@jackwener/opencli?style=flat-square)](./LICENSE) -A CLI tool that turns **any website** into a command-line interface. **47 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery. +A CLI tool that turns **any website** into a command-line interface. **56 commands** across **17 sites** — bilibili, zhihu, xiaohongshu, twitter, reddit, xueqiu, github, v2ex, hackernews, bbc, weibo, boss, yahoo-finance, reuters, smzdm, ctrip, youtube — powered by browser session reuse and AI-native discovery. --- @@ -126,7 +126,7 @@ npm install -g @jackwener/opencli@latest | **zhihu** | `hot` `search` `question` | 🔐 Browser | | **xiaohongshu** | `search` `notifications` `feed` `me` `user` | 🔐 Browser | | **xueqiu** | `feed` `hot-stock` `hot` `search` `stock` `watchlist` | 🔐 Browser | -| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` | 🔐 Browser | +| **twitter** | `trending` `bookmarks` `profile` `search` `timeline` `following` `followers` `notifications` `post` `reply` `delete` | 🔐 Browser | | **reddit** | `hot` `frontpage` `search` `subreddit` | 🔐 Browser | | **weibo** | `hot` | 🔐 Browser | | **boss** | `search` | 🔐 Browser | diff --git a/src/browser.test.ts b/src/browser.test.ts new file mode 100644 index 0000000..3498a4f --- /dev/null +++ b/src/browser.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { formatBrowserConnectError, getTokenFingerprint } from './browser.js'; + +describe('getTokenFingerprint', () => { + it('returns null for empty token', () => { + expect(getTokenFingerprint(undefined)).toBeNull(); + }); + + it('returns stable short fingerprint for token', () => { + expect(getTokenFingerprint('abc123')).toBe('6ca13d52'); + }); +}); + +describe('formatBrowserConnectError', () => { + it('explains missing extension token clearly', () => { + const err = formatBrowserConnectError({ + kind: 'missing-token', + mode: 'extension', + timeout: 30, + hasExtensionToken: false, + }); + + expect(err.message).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set'); + expect(err.message).toContain('manual approval dialog'); + }); + + it('mentions token mismatch as likely cause for extension timeout', () => { + const err = formatBrowserConnectError({ + kind: 'extension-timeout', + mode: 'extension', + timeout: 30, + hasExtensionToken: true, + tokenFingerprint: 'deadbeef', + }); + + expect(err.message).toContain('does not match the token currently shown by the browser extension'); + expect(err.message).toContain('deadbeef'); + }); + + it('keeps CDP timeout guidance separate', () => { + const err = formatBrowserConnectError({ + kind: 'cdp-timeout', + mode: 'cdp', + timeout: 30, + hasExtensionToken: false, + }); + + expect(err.message).toContain('via CDP'); + expect(err.message).toContain('chrome://inspect#remote-debugging'); + }); +}); diff --git a/src/browser.ts b/src/browser.ts index 2223a61..71b1320 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -4,6 +4,7 @@ */ import { spawn, execSync, type ChildProcess } from 'node:child_process'; +import { createHash } from 'node:crypto'; import * as net from 'node:net'; import { fileURLToPath } from 'node:url'; import * as fs from 'node:fs'; @@ -33,7 +34,7 @@ function isPortReachable(port: number, host = '127.0.0.1', timeoutMs = 800): Pro }); } -async function discoverChromeEndpoint(): Promise { +export async function discoverChromeEndpoint(): Promise { const candidates: string[] = []; // User-specified Chrome data dir takes highest priority @@ -84,6 +85,106 @@ const EXTENSION_LOCK_POLL = parseInt(process.env.OPENCLI_EXTENSION_LOCK_POLL_INT const CONNECT_TIMEOUT = parseInt(process.env.OPENCLI_BROWSER_CONNECT_TIMEOUT ?? '30', 10); const LOCK_DIR = path.join(os.tmpdir(), 'opencli-mcp-lock'); +type ConnectFailureKind = 'missing-token' | 'extension-timeout' | 'extension-not-installed' | 'cdp-timeout' | 'mcp-init' | 'process-exit' | 'unknown'; + +type ConnectFailureInput = { + kind: ConnectFailureKind; + mode: 'extension' | 'cdp'; + timeout: number; + hasExtensionToken: boolean; + tokenFingerprint?: string | null; + stderr?: string; + exitCode?: number | null; + rawMessage?: string; +}; + +export function getTokenFingerprint(token: string | undefined): string | null { + if (!token) return null; + return createHash('sha256').update(token).digest('hex').slice(0, 8); +} + +export function formatBrowserConnectError(input: ConnectFailureInput): Error { + const stderr = input.stderr?.trim(); + const suffix = stderr ? `\n\nMCP stderr:\n${stderr}` : ''; + const tokenHint = input.tokenFingerprint ? ` Token fingerprint: ${input.tokenFingerprint}.` : ''; + + if (input.mode === 'extension') { + if (input.kind === 'missing-token') { + return new Error( + 'Failed to connect to Playwright MCP Bridge: PLAYWRIGHT_MCP_EXTENSION_TOKEN is not set.\n\n' + + 'Without this token, Chrome will show a manual approval dialog for every new MCP connection. ' + + 'Copy the token from the Playwright MCP Bridge extension and set it in BOTH your shell environment and MCP client config.' + + suffix, + ); + } + + if (input.kind === 'extension-not-installed') { + return new Error( + 'Failed to connect to Playwright MCP Bridge: the browser extension did not attach.\n\n' + + 'Make sure Chrome is running and the "Playwright MCP Bridge" extension is installed and enabled. ' + + 'If Chrome shows an approval dialog, click Allow.' + + suffix, + ); + } + + if (input.kind === 'extension-timeout') { + const likelyCause = input.hasExtensionToken + ? `The most likely cause is that PLAYWRIGHT_MCP_EXTENSION_TOKEN does not match the token currently shown by the browser extension.${tokenHint} Re-copy the token from the extension and update BOTH your shell environment and MCP client config.` + : 'PLAYWRIGHT_MCP_EXTENSION_TOKEN is not configured, so the extension may be waiting for manual approval.'; + return new Error( + `Timed out connecting to Playwright MCP Bridge (${input.timeout}s).\n\n` + + `${likelyCause} If a browser prompt is visible, click Allow. You can also switch to Chrome remote debugging mode with OPENCLI_USE_CDP=1 as a fallback.` + + suffix, + ); + } + } + + if (input.mode === 'cdp' && input.kind === 'cdp-timeout') { + return new Error( + `Timed out connecting to browser via CDP (${input.timeout}s).\n\n` + + 'Make sure Chrome is running and remote debugging is enabled at chrome://inspect#remote-debugging, or set OPENCLI_CDP_ENDPOINT explicitly.' + + suffix, + ); + } + + if (input.kind === 'mcp-init') { + return new Error(`Failed to initialize Playwright MCP: ${input.rawMessage ?? 'unknown error'}${suffix}`); + } + + if (input.kind === 'process-exit') { + return new Error( + `Playwright MCP process exited before the browser connection was established${input.exitCode == null ? '' : ` (code ${input.exitCode})`}.` + + suffix, + ); + } + + return new Error(input.rawMessage ?? 'Failed to connect to browser'); +} + +function inferConnectFailureKind(args: { + mode: 'extension' | 'cdp'; + hasExtensionToken: boolean; + stderr: string; + rawMessage?: string; + exited?: boolean; +}): ConnectFailureKind { + const haystack = `${args.rawMessage ?? ''}\n${args.stderr}`.toLowerCase(); + + if (args.mode === 'extension' && !args.hasExtensionToken) + return 'missing-token'; + if (haystack.includes('extension connection timeout') || haystack.includes('playwright mcp bridge')) + return 'extension-not-installed'; + if (args.rawMessage?.startsWith('MCP init failed:')) + return 'mcp-init'; + if (args.exited) + return 'process-exit'; + if (args.mode === 'extension') + return 'extension-timeout'; + if (args.mode === 'cdp') + return 'cdp-timeout'; + return 'unknown'; +} + // JSON-RPC helpers let _nextId = 1; function jsonRpcRequest(method: string, params: Record = {}): string { @@ -350,10 +451,42 @@ export class PlaywrightMCP { return new Promise((resolve, reject) => { const isDebug = process.env.DEBUG?.includes('opencli:mcp'); const debugLog = (msg: string) => isDebug && console.error(`[opencli:mcp] ${msg}`); + const mode: 'extension' | 'cdp' = cdpEndpoint ? 'cdp' : 'extension'; + const extensionToken = process.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN; + const tokenFingerprint = getTokenFingerprint(extensionToken); + let stderrBuffer = ''; + let settled = false; + + const settleError = (kind: ConnectFailureKind, extra: { rawMessage?: string; exitCode?: number | null } = {}) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(formatBrowserConnectError({ + kind, + mode, + timeout, + hasExtensionToken: !!extensionToken, + tokenFingerprint, + stderr: stderrBuffer, + exitCode: extra.exitCode, + rawMessage: extra.rawMessage, + })); + }; + + const settleSuccess = (pageToResolve: Page) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(pageToResolve); + }; const timer = setTimeout(() => { debugLog('Connection timed out'); - reject(new Error(`Timed out connecting to browser (${timeout}s)`)); + settleError(inferConnectFailureKind({ + mode, + hasExtensionToken: !!extensionToken, + stderr: stderrBuffer, + })); }, timeout * 1000); const mcpArgs: string[] = [mcpPath]; @@ -364,6 +497,9 @@ export class PlaywrightMCP { } if (process.env.OPENCLI_VERBOSE) { console.error(`[opencli] CDP mode: ${cdpEndpoint ? `auto-discovered ${cdpEndpoint}` : 'fallback to --extension'}`); + if (mode === 'extension') { + console.error(`[opencli] Extension token: ${extensionToken ? `configured (fingerprint ${tokenFingerprint})` : 'missing'}`); + } } if (process.env.OPENCLI_BROWSER_EXECUTABLE_PATH) { mcpArgs.push('--executablePath', process.env.OPENCLI_BROWSER_EXECUTABLE_PATH); @@ -402,14 +538,25 @@ export class PlaywrightMCP { } }); - this._proc.stderr?.on('data', (chunk) => debugLog(`STDERR: ${chunk}`)); + this._proc.stderr?.on('data', (chunk: Buffer) => { + const text = chunk.toString(); + stderrBuffer += text; + debugLog(`STDERR: ${text}`); + }); this._proc.on('error', (err) => { debugLog(`Subprocess error: ${err.message}`); - clearTimeout(timer); - reject(err); + settleError('process-exit', { rawMessage: err.message }); }); this._proc.on('close', (code) => { debugLog(`Subprocess closed with code ${code}`); + if (!settled) { + settleError(inferConnectFailureKind({ + mode, + hasExtensionToken: !!extensionToken, + stderr: stderrBuffer, + exited: true, + }), { exitCode: code }); + } }); // Initialize: send initialize request @@ -426,7 +573,15 @@ export class PlaywrightMCP { debugLog('Waiting for initialize response...'); origRecv().then((resp) => { debugLog('Got initialize response'); - if (resp.error) { clearTimeout(timer); reject(new Error(`MCP init failed: ${resp.error.message}`)); return; } + if (resp.error) { + settleError(inferConnectFailureKind({ + mode, + hasExtensionToken: !!extensionToken, + stderr: stderrBuffer, + rawMessage: `MCP init failed: ${resp.error.message}`, + }), { rawMessage: resp.error.message }); + return; + } const initializedMsg = JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + '\n'; debugLog(`SEND: ${initializedMsg.trim()}`); @@ -441,17 +596,14 @@ export class PlaywrightMCP { } else if (Array.isArray(tabs)) { this._initialTabCount = tabs.length; } - clearTimeout(timer); - resolve(page); + settleSuccess(page); }).catch((err) => { debugLog(`Tabs fetch error: ${err.message}`); - clearTimeout(timer); - resolve(page); + settleSuccess(page); }); }).catch((err) => { debugLog(`Init promise rejected: ${err.message}`); - clearTimeout(timer); - reject(err); + settleError('mcp-init', { rawMessage: err.message }); }); }); } diff --git a/src/clis/twitter/delete.ts b/src/clis/twitter/delete.ts new file mode 100644 index 0000000..c7f9d6d --- /dev/null +++ b/src/clis/twitter/delete.ts @@ -0,0 +1,78 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'twitter', + name: 'delete', + description: 'Delete a specific tweet by URL', + domain: 'x.com', + strategy: Strategy.UI, // Utilizes internal DOM flows for interaction + browser: true, + args: [ + { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to delete' }, + ], + columns: ['status', 'message'], + func: async (page: IPage | null, kwargs: any) => { + if (!page) throw new Error('Requires browser'); + + console.log(`Navigating to tweet: ${kwargs.url}`); + await page.goto(kwargs.url); + await page.wait(5); // Wait for tweet to load completely + + const result = await page.evaluate(`(async () => { + try { + // Wait for caret button (which has 'More' aria-label) within the main tweet body + // Getting the first 'More' usually corresponds to the main displayed tweet of the URL + const moreMenu = document.querySelector('[aria-label="More"]'); + if (!moreMenu) { + return { ok: false, message: 'Could not find the "More" context menu on this tweet. Are you sure you are logged in and looking at a valid tweet?' }; + } + + // Click the 'More' 3 dots button to open the dropdown menu + moreMenu.click(); + await new Promise(r => setTimeout(r, 1000)); + + // Wait for dropdown pop-out to appear and look for the 'Delete' option + const items = document.querySelectorAll('[role="menuitem"]'); + let deleteBtn = null; + for (const item of items) { + if (item.textContent.includes('Delete') && !item.textContent.includes('List')) { + deleteBtn = item; + break; + } + } + + if (!deleteBtn) { + // If there's no Delete button, it's not our tweet OR localization is not English. + // Assuming English default for now. + return { ok: false, message: 'This tweet does not seem to belong to you, or the Delete option is missing (not your tweet).' }; + } + + // Click Delete + deleteBtn.click(); + await new Promise(r => setTimeout(r, 1000)); + + // Find and click the confirmation 'Delete' prompt inside the modal + const confirmBtn = document.querySelector('[data-testid="confirmationSheetConfirm"]'); + if (confirmBtn) { + confirmBtn.click(); + return { ok: true, message: 'Tweet successfully deleted.' }; + } else { + return { ok: false, message: 'Delete confirmation dialog did not appear.' }; + } + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + if (result.ok) { + // Wait for the deletion request to be processed + await page.wait(2); + } + + return [{ + status: result.ok ? 'success' : 'failed', + message: result.message + }]; + } +}); diff --git a/src/clis/twitter/followers.ts b/src/clis/twitter/followers.ts new file mode 100644 index 0000000..2476f25 --- /dev/null +++ b/src/clis/twitter/followers.ts @@ -0,0 +1,119 @@ +import { cli, Strategy } from '../../registry.js'; +import * as fs from 'fs'; + +cli({ + site: 'twitter', + name: 'followers', + description: 'Get accounts following a Twitter/X user', + domain: 'x.com', + strategy: Strategy.INTERCEPT, + browser: true, + args: [ + { name: 'user', type: 'string', required: false }, + { name: 'limit', type: 'int', default: 50 }, + ], + columns: ['screen_name', 'name', 'bio', 'followers'], + func: async (page, kwargs) => { + let targetUser = kwargs.user; + + // If no user is specified, we must figure out the logged-in user's handle + if (!targetUser) { + await page.goto('https://x.com/home'); + // wait for home page navigation + await page.wait(5); + + const href = await page.evaluate(`() => { + const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]'); + return link ? link.getAttribute('href') : null; + }`); + + if (!href) { + throw new Error('Could not find logged-in user profile link. Are you logged in?'); + } + targetUser = href.replace('/', ''); + } + + // 1. Navigate to user profile page + await page.goto(`https://x.com/${targetUser}`); + await page.wait(3); + + // 2. Inject interceptor for Followers GraphQL API (or user_flow.json) + await page.installInterceptor('graphql'); + + // 3. Click the followers link inside the profile page + await page.evaluate(`() => { + const target = '${targetUser}'; + const link = document.querySelector('a[href="/' + target + '/followers"]'); + if (link) link.click(); + }`); + await page.wait(3); + + // 4. Trigger API by scrolling + await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 }); + + // 4. Retrieve data from opencli's registered interceptors + const allRequests = await page.getInterceptedRequests(); + + // Debug: Force dump all intercepted XHRs that match followers + if (!allRequests || allRequests.length === 0) { + console.log('No GraphQL requests captured by the interceptor backend.'); + return []; + } + + console.log('Intercepted keys:', allRequests.map((r: any) => { + try { + const u = new URL(r.url); return u.pathname; + } catch (e) { + return r.url; + } + })); + + const requests = allRequests.filter((r: any) => r.url.includes('Followers')); + if (!requests || requests.length === 0) { + console.log('No specific Followers requests captured. Check keys printed above.'); + return []; + } + + let results: any[] = []; + for (const req of requests) { + try { + let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions; + if (!instructions) continue; + + let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries'); + if (!addEntries) { + addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries)); + } + + if (!addEntries) continue; + + for (const entry of addEntries.entries) { + if (!entry.entryId.startsWith('user-')) continue; + + const item = entry.content?.itemContent?.user_results?.result; + if (!item || item.__typename !== 'User') continue; + + // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state + const core = item.core || {}; + const legacy = item.legacy || {}; + + results.push({ + screen_name: core.screen_name || legacy.screen_name || 'unknown', + name: core.name || legacy.name || 'unknown', + bio: legacy.description || item.profile_bio?.description || '', + followers: legacy.followers_count || legacy.normal_followers_count || 0 + }); + } + } catch (e) { + // ignore parsing errors for individual payloads + } + } + + // Deduplicate by screen_name in case multiple scrolls caught the same + const unique = new Map(); + results.forEach(r => unique.set(r.screen_name, r)); + const deduplicatedResults = Array.from(unique.values()); + + return deduplicatedResults.slice(0, kwargs.limit); + } +}); diff --git a/src/clis/twitter/following.ts b/src/clis/twitter/following.ts new file mode 100644 index 0000000..8ceb871 --- /dev/null +++ b/src/clis/twitter/following.ts @@ -0,0 +1,105 @@ +import { cli, Strategy } from '../../registry.js'; +import * as fs from 'fs'; + +cli({ + site: 'twitter', + name: 'following', + description: 'Get accounts a Twitter/X user is following', + domain: 'x.com', + strategy: Strategy.INTERCEPT, + browser: true, + args: [ + { name: 'user', type: 'string', required: false }, + { name: 'limit', type: 'int', default: 50 }, + ], + columns: ['screen_name', 'name', 'bio', 'followers'], + func: async (page, kwargs) => { + let targetUser = kwargs.user; + + // If no user is specified, we must figure out the logged-in user's handle + if (!targetUser) { + await page.goto('https://x.com/home'); + // wait for home page navigation + await page.wait(5); + + const href = await page.evaluate(`() => { + const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]'); + return link ? link.getAttribute('href') : null; + }`); + + if (!href) { + throw new Error('Could not find logged-in user profile link. Are you logged in?'); + } + targetUser = href.replace('/', ''); + } + + // 1. Navigate to user profile page + await page.goto(`https://x.com/${targetUser}`); + await page.wait(3); + + // 2. Inject interceptor for Following GraphQL API + await page.installInterceptor('Following'); + + // 3. Click the following link inside the profile page + await page.evaluate(`() => { + const target = '${targetUser}'; + const link = document.querySelector('a[href="/' + target + '/following"]'); + if (link) link.click(); + }`); + await page.wait(3); + + // 4. Trigger API by scrolling + await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 }); + + // 4. Retrieve data from opencli's registered interceptors + const requests = await page.getInterceptedRequests(); + + // Debug: Force dump all intercepted XHRs that match following + if (!requests || requests.length === 0) { + console.log('No Following requests captured by the interceptor backend.'); + return []; + } + + let results: any[] = []; + for (const req of requests) { + try { + let instructions = req.data?.data?.user?.result?.timeline?.timeline?.instructions; + if (!instructions) continue; + + let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries'); + if (!addEntries) { + addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries)); + } + + if (!addEntries) continue; + + for (const entry of addEntries.entries) { + if (!entry.entryId.startsWith('user-')) continue; + + const item = entry.content?.itemContent?.user_results?.result; + if (!item || item.__typename !== 'User') continue; + + // Twitter GraphQL sometimes nests `core` differently depending on the endpoint profile state + const core = item.core || {}; + const legacy = item.legacy || {}; + + results.push({ + screen_name: core.screen_name || legacy.screen_name || 'unknown', + name: core.name || legacy.name || 'unknown', + bio: legacy.description || item.profile_bio?.description || '', + followers: legacy.followers_count || legacy.normal_followers_count || 0 + }); + } + } catch (e) { + // ignore parsing errors for individual payloads + } + } + + // Deduplicate by screen_name in case multiple scrolls caught the same + const unique = new Map(); + results.forEach(r => unique.set(r.screen_name, r)); + const deduplicatedResults = Array.from(unique.values()); + + return deduplicatedResults.slice(0, kwargs.limit); + } +}); diff --git a/src/clis/twitter/notifications.ts b/src/clis/twitter/notifications.ts new file mode 100644 index 0000000..2a454b8 --- /dev/null +++ b/src/clis/twitter/notifications.ts @@ -0,0 +1,119 @@ +import { cli, Strategy } from '../../registry.js'; +import * as fs from 'fs'; + +cli({ + site: 'twitter', + name: 'notifications', + description: 'Get Twitter/X notifications', + domain: 'x.com', + strategy: Strategy.INTERCEPT, + browser: true, + args: [ + { name: 'limit', type: 'int', default: 20 }, + ], + columns: ['id', 'action', 'author', 'text', 'url'], + func: async (page, kwargs) => { + // 1. Navigate to notifications + await page.goto('https://x.com/notifications'); + await page.wait(5); + + // 2. Inject interceptor + await page.installInterceptor('NotificationsTimeline'); + + // 3. Trigger API by scrolling (if we need to load more) + await page.autoScroll({ times: 2, delayMs: 2000 }); + + // 4. Retrieve data + const requests = await page.getInterceptedRequests(); + if (!requests || requests.length === 0) return []; + + let results: any[] = []; + for (const req of requests) { + try { + let instructions = []; + if (req.data?.data?.viewer?.timeline_response?.timeline?.instructions) { + instructions = req.data.data.viewer.timeline_response.timeline.instructions; + } else if (req.data?.data?.viewer_v2?.user_results?.result?.notification_timeline?.timeline?.instructions) { + instructions = req.data.data.viewer_v2.user_results.result.notification_timeline.timeline.instructions; + } else if (req.data?.data?.timeline?.instructions) { + instructions = req.data.data.timeline.instructions; + } + + let addEntries = instructions.find((i: any) => i.type === 'TimelineAddEntries'); + + // Sometimes it's the first object without a 'type' field but has 'entries' + if (!addEntries) { + addEntries = instructions.find((i: any) => i.entries && Array.isArray(i.entries)); + } + + if (!addEntries) continue; + + for (const entry of addEntries.entries) { + if (!entry.entryId.startsWith('notification-')) { + if (entry.content?.items) { + for (const subItem of entry.content.items) { + processNotificationItem(subItem.item?.itemContent, subItem.entryId); + } + } + continue; + } + + processNotificationItem(entry.content?.itemContent, entry.entryId); + } + + function processNotificationItem(itemContent: any, entryId: string) { + if (!itemContent) return; + + // Twitter wraps standard notifications + let item = itemContent?.notification_results?.result || itemContent?.tweet_results?.result || itemContent; + + let actionText = 'Notification'; + let author = 'unknown'; + let text = ''; + let urlStr = ''; + + if (item.__typename === 'TimelineNotification') { + // Greet likes, retweet, mentions + text = item.rich_message?.text || item.message?.text || ''; + author = item.template?.from_users?.[0]?.user_results?.result?.core?.screen_name || 'unknown'; + urlStr = item.notification_url?.url || ''; + actionText = item.notification_icon || 'Activity'; + + // If there's an attached tweet + const targetTweet = item.template?.target_objects?.[0]?.tweet_results?.result; + if (targetTweet) { + text += ' | ' + (targetTweet.legacy?.full_text || ''); + if (!urlStr) { + urlStr = `https://x.com/i/status/${targetTweet.rest_id}`; + } + } + } else if (item.__typename === 'TweetNotification') { + // Direct mention/reply + const tweet = item.tweet_result?.result; + author = tweet?.core?.user_results?.result?.legacy?.screen_name || 'unknown'; + text = tweet?.legacy?.full_text || item.message?.text || ''; + actionText = 'Mention/Reply'; + urlStr = `https://x.com/i/status/${tweet?.rest_id}`; + } else if (item.__typename === 'Tweet') { + author = item.core?.user_results?.result?.legacy?.screen_name || 'unknown'; + text = item.legacy?.full_text || ''; + actionText = 'Mention'; + urlStr = `https://x.com/i/status/${item.rest_id}`; + } + + results.push({ + id: item.id || item.rest_id || entryId, + action: actionText, + author: author, + text: text, + url: urlStr || `https://x.com/notifications` + }); + } + } catch (e) { + // ignore parsing errors for individual payloads + } + } + + return results.slice(0, kwargs.limit); + } +}); diff --git a/src/clis/twitter/post.ts b/src/clis/twitter/post.ts new file mode 100644 index 0000000..cee2203 --- /dev/null +++ b/src/clis/twitter/post.ts @@ -0,0 +1,68 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'twitter', + name: 'post', + description: 'Post a new tweet/thread', + domain: 'x.com', + strategy: Strategy.UI, + browser: true, + args: [ + { name: 'text', type: 'string', required: true, help: 'The text content of the tweet' }, + ], + columns: ['status', 'message', 'text'], + func: async (page: IPage | null, kwargs: any) => { + if (!page) throw new Error('Requires browser'); + + // 1. Navigate directly to the compose tweet modal + await page.goto('https://x.com/compose/tweet'); + await page.wait(3); // Wait for the modal and React app to hydrate + + // 2. Automate typing and clicking + const result = await page.evaluate(`(async () => { + try { + // Find the active text area + const box = document.querySelector('[data-testid="tweetTextarea_0"]'); + if (box) { + box.focus(); + // insertText is the most reliable way to trigger React's onChange events + document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)}); + } else { + return { ok: false, message: 'Could not find the tweet composer text area.' }; + } + + // Wait a brief moment for the button state to update + await new Promise(r => setTimeout(r, 1000)); + + // Click the post button + const btn = document.querySelector('[data-testid="tweetButton"]'); + if (btn && !btn.disabled) { + btn.click(); + return { ok: true, message: 'Tweet posted successfully.' }; + } else { + // Sometimes it's rendered inline depending on the viewport + const inlineBtn = document.querySelector('[data-testid="tweetButtonInline"]'); + if (inlineBtn && !inlineBtn.disabled) { + inlineBtn.click(); + return { ok: true, message: 'Tweet posted successfully.' }; + } + return { ok: false, message: 'Tweet button is disabled or not found.' }; + } + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + // 3. Wait a few seconds for the network request to finish sending + if (result.ok) { + await page.wait(3); + } + + return [{ + status: result.ok ? 'success' : 'failed', + message: result.message, + text: kwargs.text + }]; + } +}); diff --git a/src/clis/twitter/reply.ts b/src/clis/twitter/reply.ts new file mode 100644 index 0000000..73925e0 --- /dev/null +++ b/src/clis/twitter/reply.ts @@ -0,0 +1,62 @@ +import { cli, Strategy } from '../../registry.js'; +import type { IPage } from '../../types.js'; + +cli({ + site: 'twitter', + name: 'reply', + description: 'Reply to a specific tweet', + domain: 'x.com', + strategy: Strategy.UI, // Uses the UI directly to input and click post + browser: true, + args: [ + { name: 'url', type: 'string', required: true, help: 'The URL of the tweet to reply to' }, + { name: 'text', type: 'string', required: true, help: 'The text content of your reply' }, + ], + columns: ['status', 'message', 'text'], + func: async (page: IPage | null, kwargs: any) => { + if (!page) throw new Error('Requires browser'); + + // 1. Navigate to the tweet page + await page.goto(kwargs.url); + await page.wait(5); // Wait for the react application to hydrate + + // 2. Automate typing the reply and clicking reply + const result = await page.evaluate(`(async () => { + try { + // Find the reply text area on the tweet page. + // The placeholder is usually "Post your reply" + const box = document.querySelector('[data-testid="tweetTextarea_0"]'); + if (box) { + box.focus(); + document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)}); + } else { + return { ok: false, message: 'Could not find the reply text area. Are you logged in?' }; + } + + // Wait for React state to register the input and enable the button + await new Promise(r => setTimeout(r, 1000)); + + // Find the Reply button. It usually shares the same test id tweetButtonInline in this context + const btn = document.querySelector('[data-testid="tweetButtonInline"]'); + if (btn && !btn.disabled) { + btn.click(); + return { ok: true, message: 'Reply posted successfully.' }; + } else { + return { ok: false, message: 'Reply button is disabled or not found.' }; + } + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); + + if (result.ok) { + await page.wait(3); // Wait for network submission to complete + } + + return [{ + status: result.ok ? 'success' : 'failed', + message: result.message, + text: kwargs.text + }]; + } +}); diff --git a/src/doctor.test.ts b/src/doctor.test.ts new file mode 100644 index 0000000..4ec5c60 --- /dev/null +++ b/src/doctor.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { + readTokenFromShellContent, + renderBrowserDoctorReport, + upsertShellToken, + readTomlConfigToken, + upsertTomlConfigToken, + upsertJsonConfigToken, +} from './doctor.js'; + +describe('shell token helpers', () => { + it('reads token from shell export', () => { + expect(readTokenFromShellContent('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"\n')).toBe('abc123'); + }); + + it('appends token export when missing', () => { + const next = upsertShellToken('export PATH="/usr/bin"\n', 'abc123'); + expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="abc123"'); + }); + + it('replaces token export when present', () => { + const next = upsertShellToken('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="old"\n', 'new'); + expect(next).toContain('export PLAYWRIGHT_MCP_EXTENSION_TOKEN="new"'); + expect(next).not.toContain('"old"'); + }); +}); + +describe('toml token helpers', () => { + it('reads token from playwright env section', () => { + const content = ` +[mcp_servers.playwright.env] +PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123" +`; + expect(readTomlConfigToken(content)).toBe('abc123'); + }); + + it('updates token inside existing env section', () => { + const content = ` +[mcp_servers.playwright.env] +PLAYWRIGHT_MCP_EXTENSION_TOKEN = "old" +`; + const next = upsertTomlConfigToken(content, 'new'); + expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "new"'); + expect(next).not.toContain('"old"'); + }); + + it('creates env section when missing', () => { + const content = ` +[mcp_servers.playwright] +type = "stdio" +`; + const next = upsertTomlConfigToken(content, 'abc123'); + expect(next).toContain('[mcp_servers.playwright.env]'); + expect(next).toContain('PLAYWRIGHT_MCP_EXTENSION_TOKEN = "abc123"'); + }); +}); + +describe('json token helpers', () => { + it('writes token into standard mcpServers config', () => { + const next = upsertJsonConfigToken(JSON.stringify({ + mcpServers: { + playwright: { + command: 'npx', + args: ['-y', '@playwright/mcp@latest', '--extension'], + }, + }, + }), 'abc123'); + const parsed = JSON.parse(next); + expect(parsed.mcpServers.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123'); + }); + + it('writes token into opencode mcp config', () => { + const next = upsertJsonConfigToken(JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + mcp: { + playwright: { + command: ['npx', '-y', '@playwright/mcp@latest', '--extension'], + enabled: true, + type: 'local', + }, + }, + }), 'abc123'); + const parsed = JSON.parse(next); + expect(parsed.mcp.playwright.env.PLAYWRIGHT_MCP_EXTENSION_TOKEN).toBe('abc123'); + }); +}); + +describe('doctor report rendering', () => { + it('renders OK-style report when tokens match', () => { + const text = renderBrowserDoctorReport({ + envToken: 'abc123', + envFingerprint: 'fp1', + shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'abc123', fingerprint: 'fp1' }], + configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }], + remoteDebuggingEnabled: true, + remoteDebuggingEndpoint: 'ws://127.0.0.1:9222/devtools/browser/test', + cdpEnabled: false, + cdpToken: null, + cdpFingerprint: null, + recommendedToken: 'abc123', + recommendedFingerprint: 'fp1', + warnings: [], + issues: [], + }); + + expect(text).toContain('[OK] Chrome remote debugging: enabled'); + expect(text).toContain('[OK] Environment token: configured (fp1)'); + expect(text).toContain('[OK] MCP config /tmp/mcp.json: configured (fp1)'); + }); + + it('renders MISMATCH-style report when fingerprints differ', () => { + const text = renderBrowserDoctorReport({ + envToken: 'abc123', + envFingerprint: 'fp1', + shellFiles: [{ path: '/tmp/.zshrc', exists: true, token: 'def456', fingerprint: 'fp2' }], + configs: [{ path: '/tmp/mcp.json', exists: true, format: 'json', token: 'abc123', fingerprint: 'fp1', writable: true }], + remoteDebuggingEnabled: false, + remoteDebuggingEndpoint: null, + cdpEnabled: false, + cdpToken: null, + cdpFingerprint: null, + recommendedToken: 'abc123', + recommendedFingerprint: 'fp1', + warnings: ['Chrome remote debugging appears to be disabled or Chrome is not currently exposing a DevTools endpoint.'], + issues: ['Detected inconsistent Playwright MCP tokens across env/config files.'], + }); + + expect(text).toContain('[WARN] Chrome remote debugging: disabled'); + expect(text).toContain('[MISMATCH] Environment token: configured (fp1)'); + expect(text).toContain('[MISMATCH] Shell file /tmp/.zshrc: configured (fp2)'); + expect(text).toContain('[MISMATCH] Recommended token fingerprint: fp1'); + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts new file mode 100644 index 0000000..b55ca55 --- /dev/null +++ b/src/doctor.ts @@ -0,0 +1,421 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createInterface } from 'node:readline/promises'; +import { stdin as input, stdout as output } from 'node:process'; +import type { IPage } from './types.js'; +import { PlaywrightMCP, discoverChromeEndpoint, getTokenFingerprint } from './browser.js'; +import { browserSession } from './runtime.js'; + +const PLAYWRIGHT_SERVER_NAME = 'playwright'; +const PLAYWRIGHT_TOKEN_ENV = 'PLAYWRIGHT_MCP_EXTENSION_TOKEN'; +const PLAYWRIGHT_EXTENSION_ID = 'mmlmfjhmonkocbjadbfplnigmagldckm'; +const TOKEN_LINE_RE = /^(\s*export\s+PLAYWRIGHT_MCP_EXTENSION_TOKEN=)(['"]?)([^'"\\\n]+)\2\s*$/m; +export type DoctorOptions = { + fix?: boolean; + yes?: boolean; + shellRc?: string; + configPaths?: string[]; + token?: string; +}; + +export type ShellFileStatus = { + path: string; + exists: boolean; + token: string | null; + fingerprint: string | null; +}; + +export type McpConfigFormat = 'json' | 'toml'; + +export type McpConfigStatus = { + path: string; + exists: boolean; + format: McpConfigFormat; + token: string | null; + fingerprint: string | null; + writable: boolean; + parseError?: string; +}; + +export type DoctorReport = { + envToken: string | null; + envFingerprint: string | null; + shellFiles: ShellFileStatus[]; + configs: McpConfigStatus[]; + remoteDebuggingEnabled: boolean; + remoteDebuggingEndpoint: string | null; + cdpEnabled: boolean; + cdpToken: string | null; + cdpFingerprint: string | null; + recommendedToken: string | null; + recommendedFingerprint: string | null; + warnings: string[]; + issues: string[]; +}; + +type ReportStatus = 'OK' | 'MISSING' | 'MISMATCH' | 'WARN'; + +function label(status: ReportStatus): string { + return `[${status}]`; +} + +function statusLine(status: ReportStatus, text: string): string { + return `${label(status)} ${text}`; +} + +function tokenSummary(token: string | null, fingerprint: string | null): string { + if (!token) return 'missing'; + return `configured (${fingerprint})`; +} + +export function getDefaultShellRcPath(): string { + const shell = process.env.SHELL ?? ''; + if (shell.endsWith('/bash')) return path.join(os.homedir(), '.bashrc'); + if (shell.endsWith('/fish')) return path.join(os.homedir(), '.config', 'fish', 'config.fish'); + return path.join(os.homedir(), '.zshrc'); +} + +export function getDefaultMcpConfigPaths(cwd: string = process.cwd()): string[] { + const home = os.homedir(); + const candidates = [ + path.join(home, '.codex', 'config.toml'), + path.join(home, '.codex', 'mcp.json'), + path.join(home, '.cursor', 'mcp.json'), + path.join(home, '.config', 'opencode', 'opencode.json'), + path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'), + path.join(home, '.config', 'Claude', 'claude_desktop_config.json'), + path.join(cwd, '.cursor', 'mcp.json'), + path.join(cwd, '.vscode', 'mcp.json'), + path.join(cwd, '.opencode', 'opencode.json'), + ]; + return [...new Set(candidates)]; +} + +export function readTokenFromShellContent(content: string): string | null { + const m = content.match(TOKEN_LINE_RE); + return m?.[3] ?? null; +} + +export function upsertShellToken(content: string, token: string): string { + const nextLine = `export ${PLAYWRIGHT_TOKEN_ENV}="${token}"`; + if (!content.trim()) return `${nextLine}\n`; + if (TOKEN_LINE_RE.test(content)) return content.replace(TOKEN_LINE_RE, `$1"${ + token + }"`); + return `${content.replace(/\s*$/, '')}\n${nextLine}\n`; +} + +function readJsonConfigToken(content: string): string | null { + try { + const parsed = JSON.parse(content); + return readTokenFromJsonObject(parsed); + } catch { + return null; + } +} + +function readTokenFromJsonObject(parsed: any): string | null { + const direct = parsed?.mcpServers?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV]; + if (typeof direct === 'string' && direct) return direct; + const opencode = parsed?.mcp?.[PLAYWRIGHT_SERVER_NAME]?.env?.[PLAYWRIGHT_TOKEN_ENV]; + if (typeof opencode === 'string' && opencode) return opencode; + return null; +} + +export function upsertJsonConfigToken(content: string, token: string): string { + const parsed = content.trim() ? JSON.parse(content) : {}; + if (parsed?.mcpServers) { + parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME] ?? { + command: 'npx', + args: ['-y', '@playwright/mcp@latest', '--extension'], + }; + parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env = parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env ?? {}; + parsed.mcpServers[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token; + } else { + parsed.mcp = parsed.mcp ?? {}; + parsed.mcp[PLAYWRIGHT_SERVER_NAME] = parsed.mcp[PLAYWRIGHT_SERVER_NAME] ?? { + command: ['npx', '-y', '@playwright/mcp@latest', '--extension'], + enabled: true, + type: 'local', + }; + parsed.mcp[PLAYWRIGHT_SERVER_NAME].env = parsed.mcp[PLAYWRIGHT_SERVER_NAME].env ?? {}; + parsed.mcp[PLAYWRIGHT_SERVER_NAME].env[PLAYWRIGHT_TOKEN_ENV] = token; + } + return `${JSON.stringify(parsed, null, 2)}\n`; +} + +export function readTomlConfigToken(content: string): string | null { + const sectionMatch = content.match(/\[mcp_servers\.playwright\.env\][\s\S]*?(?=\n\[|$)/); + if (!sectionMatch) return null; + const tokenMatch = sectionMatch[0].match(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=\s*"([^"\n]+)"/m); + return tokenMatch?.[1] ?? null; +} + +export function upsertTomlConfigToken(content: string, token: string): string { + const envSectionRe = /(\[mcp_servers\.playwright\.env\][\s\S]*?)(?=\n\[|$)/; + const tokenLine = `PLAYWRIGHT_MCP_EXTENSION_TOKEN = "${token}"`; + if (envSectionRe.test(content)) { + return content.replace(envSectionRe, (section) => { + if (/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=/m.test(section)) { + return section.replace(/^\s*PLAYWRIGHT_MCP_EXTENSION_TOKEN\s*=.*$/m, tokenLine); + } + return `${section.replace(/\s*$/, '')}\n${tokenLine}\n`; + }); + } + + const baseSectionRe = /(\[mcp_servers\.playwright\][\s\S]*?)(?=\n\[|$)/; + if (baseSectionRe.test(content)) { + return content.replace(baseSectionRe, (section) => `${section.replace(/\s*$/, '')}\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`); + } + + const prefix = content.trim() ? `${content.replace(/\s*$/, '')}\n\n` : ''; + return `${prefix}[mcp_servers.playwright]\ntype = "stdio"\ncommand = "npx"\nargs = ["-y", "@playwright/mcp@latest", "--extension"]\n\n[mcp_servers.playwright.env]\n${tokenLine}\n`; +} + +function fileExists(filePath: string): boolean { + try { + return fs.existsSync(filePath); + } catch { + return false; + } +} + +function canWrite(filePath: string): boolean { + try { + if (fileExists(filePath)) { + fs.accessSync(filePath, fs.constants.W_OK); + return true; + } + fs.accessSync(path.dirname(filePath), fs.constants.W_OK); + return true; + } catch { + return false; + } +} + +function readConfigStatus(filePath: string): McpConfigStatus { + const format: McpConfigFormat = filePath.endsWith('.toml') ? 'toml' : 'json'; + if (!fileExists(filePath)) { + return { path: filePath, exists: false, format, token: null, fingerprint: null, writable: canWrite(filePath) }; + } + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const token = format === 'toml' ? readTomlConfigToken(content) : readJsonConfigToken(content); + return { + path: filePath, + exists: true, + format, + token, + fingerprint: getTokenFingerprint(token ?? undefined), + writable: canWrite(filePath), + }; + } catch (error: any) { + return { + path: filePath, + exists: true, + format, + token: null, + fingerprint: null, + writable: canWrite(filePath), + parseError: error?.message ?? String(error), + }; + } +} + +async function extractTokenViaCdp(): Promise { + if (!(process.env.OPENCLI_USE_CDP === '1' || process.env.OPENCLI_CDP_ENDPOINT)) + return null; + const candidates = [ + `chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/options.html`, + `chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/popup.html`, + `chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/connect.html`, + `chrome-extension://${PLAYWRIGHT_EXTENSION_ID}/index.html`, + ]; + const result = await browserSession(PlaywrightMCP, async (page: IPage) => { + for (const url of candidates) { + try { + await page.goto(url); + await page.wait(1); + const token = await page.evaluate(`() => { + const values = new Set(); + const push = (value) => { + if (!value || typeof value !== 'string') return; + for (const match of value.matchAll(/[A-Za-z0-9_-]{24,}/g)) values.add(match[0]); + }; + document.querySelectorAll('input, textarea, code, pre, span, div').forEach((el) => { + push(el.value); + push(el.textContent || ''); + push(el.getAttribute && el.getAttribute('value')); + }); + return Array.from(values); + }`); + const matches = Array.isArray(token) ? token.filter((v: string) => v.length >= 24) : []; + if (matches.length > 0) return matches.sort((a: string, b: string) => b.length - a.length)[0]; + } catch {} + } + return null; + }); + return typeof result === 'string' && result ? result : null; +} + +export async function runBrowserDoctor(opts: DoctorOptions = {}): Promise { + const envToken = process.env[PLAYWRIGHT_TOKEN_ENV] ?? null; + const remoteDebuggingEndpoint = await discoverChromeEndpoint().catch(() => null); + const shellPath = opts.shellRc ?? getDefaultShellRcPath(); + const shellFiles: ShellFileStatus[] = [shellPath].map((filePath) => { + if (!fileExists(filePath)) return { path: filePath, exists: false, token: null, fingerprint: null }; + const content = fs.readFileSync(filePath, 'utf-8'); + const token = readTokenFromShellContent(content); + return { path: filePath, exists: true, token, fingerprint: getTokenFingerprint(token ?? undefined) }; + }); + const configPaths = opts.configPaths?.length ? opts.configPaths : getDefaultMcpConfigPaths(); + const configs = configPaths.map(readConfigStatus); + const cdpToken = !opts.token && !envToken ? await extractTokenViaCdp().catch(() => null) : null; + + const allTokens = [ + opts.token ?? null, + envToken, + ...shellFiles.map(s => s.token), + ...configs.map(c => c.token), + cdpToken, + ].filter((v): v is string => !!v); + const uniqueTokens = [...new Set(allTokens)]; + const recommendedToken = opts.token ?? envToken ?? (uniqueTokens.length === 1 ? uniqueTokens[0] : cdpToken) ?? null; + + const report: DoctorReport = { + envToken, + envFingerprint: getTokenFingerprint(envToken ?? undefined), + shellFiles, + configs, + remoteDebuggingEnabled: !!remoteDebuggingEndpoint, + remoteDebuggingEndpoint, + cdpEnabled: process.env.OPENCLI_USE_CDP === '1' || !!process.env.OPENCLI_CDP_ENDPOINT, + cdpToken, + cdpFingerprint: getTokenFingerprint(cdpToken ?? undefined), + recommendedToken, + recommendedFingerprint: getTokenFingerprint(recommendedToken ?? undefined), + warnings: [], + issues: [], + }; + + if (!envToken) report.issues.push(`Current environment is missing ${PLAYWRIGHT_TOKEN_ENV}.`); + if (!shellFiles.some(s => s.token)) report.issues.push('Shell startup file does not export PLAYWRIGHT_MCP_EXTENSION_TOKEN.'); + if (!configs.some(c => c.token)) report.issues.push('No scanned MCP config currently contains a Playwright extension token.'); + if (uniqueTokens.length > 1) report.issues.push('Detected inconsistent Playwright MCP tokens across env/config files.'); + if (!report.remoteDebuggingEnabled) report.warnings.push('Chrome remote debugging appears to be disabled or Chrome is not currently exposing a DevTools endpoint.'); + for (const config of configs) { + if (config.parseError) report.warnings.push(`Could not parse ${config.path}: ${config.parseError}`); + } + if (!recommendedToken) { + if (report.cdpEnabled) report.warnings.push('CDP is enabled, but no token could be extracted automatically from the extension UI.'); + else report.warnings.push('No token source found. Enable OPENCLI_USE_CDP=1 to allow a best-effort token read from the extension page.'); + } + return report; +} + +export function renderBrowserDoctorReport(report: DoctorReport): string { + const tokenFingerprints = [ + report.envFingerprint, + ...report.shellFiles.map(shell => shell.fingerprint), + ...report.configs.filter(config => config.exists).map(config => config.fingerprint), + ].filter((value): value is string => !!value); + const uniqueFingerprints = [...new Set(tokenFingerprints)]; + const hasMismatch = uniqueFingerprints.length > 1; + const lines = ['opencli doctor', '']; + lines.push(statusLine(report.remoteDebuggingEnabled ? 'OK' : 'WARN', `Chrome remote debugging: ${report.remoteDebuggingEnabled ? 'enabled' : 'disabled'}`)); + if (report.remoteDebuggingEndpoint) lines.push(` ${report.remoteDebuggingEndpoint}`); + + const envStatus: ReportStatus = !report.envToken ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK'; + lines.push(statusLine(envStatus, `Environment token: ${tokenSummary(report.envToken, report.envFingerprint)}`)); + + for (const shell of report.shellFiles) { + const shellStatus: ReportStatus = !shell.token ? 'MISSING' : hasMismatch ? 'MISMATCH' : 'OK'; + lines.push(statusLine(shellStatus, `Shell file ${shell.path}: ${tokenSummary(shell.token, shell.fingerprint)}`)); + } + const existingConfigs = report.configs.filter(config => config.exists); + const missingConfigCount = report.configs.length - existingConfigs.length; + if (existingConfigs.length > 0) { + for (const config of existingConfigs) { + const parseSuffix = config.parseError ? ` (parse error: ${config.parseError})` : ''; + const configStatus: ReportStatus = config.parseError + ? 'WARN' + : !config.token + ? 'MISSING' + : hasMismatch + ? 'MISMATCH' + : 'OK'; + lines.push(statusLine(configStatus, `MCP config ${config.path}: ${tokenSummary(config.token, config.fingerprint)}${parseSuffix}`)); + } + } else { + lines.push(statusLine('MISSING', 'MCP config: no existing config files found in scanned locations')); + } + if (missingConfigCount > 0) lines.push(` Other scanned config locations not present: ${missingConfigCount}`); + if (report.cdpEnabled) { + const cdpStatus: ReportStatus = report.cdpToken ? 'OK' : 'WARN'; + lines.push(statusLine(cdpStatus, `CDP token probe: ${tokenSummary(report.cdpToken, report.cdpFingerprint)}`)); + } + lines.push(''); + lines.push(statusLine( + hasMismatch ? 'MISMATCH' : report.recommendedToken ? 'OK' : 'WARN', + `Recommended token fingerprint: ${report.recommendedFingerprint ?? 'unavailable'}`, + )); + if (report.issues.length) { + lines.push('', 'Issues:'); + for (const issue of report.issues) lines.push(`- ${issue}`); + } + if (report.warnings.length) { + lines.push('', 'Warnings:'); + for (const warning of report.warnings) lines.push(`- ${warning}`); + } + return lines.join('\n'); +} + +async function confirmPrompt(question: string): Promise { + const rl = createInterface({ input, output }); + try { + const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase(); + return answer === 'y' || answer === 'yes'; + } finally { + rl.close(); + } +} + +function writeFileWithMkdir(filePath: string, content: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); +} + +export async function applyBrowserDoctorFix(report: DoctorReport, opts: DoctorOptions = {}): Promise { + const token = opts.token ?? report.recommendedToken; + if (!token) throw new Error('No Playwright MCP token is available to write. Provide --token or enable CDP token probing first.'); + + const plannedWrites: string[] = []; + const shellPath = opts.shellRc ?? report.shellFiles[0]?.path ?? getDefaultShellRcPath(); + plannedWrites.push(shellPath); + for (const config of report.configs) { + if (!config.writable) continue; + plannedWrites.push(config.path); + } + + if (!opts.yes) { + const ok = await confirmPrompt(`Update ${plannedWrites.length} file(s) with Playwright MCP token fingerprint ${getTokenFingerprint(token)}?`); + if (!ok) return []; + } + + const written: string[] = []; + const shellBefore = fileExists(shellPath) ? fs.readFileSync(shellPath, 'utf-8') : ''; + writeFileWithMkdir(shellPath, upsertShellToken(shellBefore, token)); + written.push(shellPath); + + for (const config of report.configs) { + if (!config.writable || config.parseError) continue; + const before = fileExists(config.path) ? fs.readFileSync(config.path, 'utf-8') : ''; + const next = config.format === 'toml' ? upsertTomlConfigToken(before, token) : upsertJsonConfigToken(before, token); + writeFileWithMkdir(config.path, next); + written.push(config.path); + } + + process.env[PLAYWRIGHT_TOKEN_ENV] = token; + return written; +} diff --git a/src/main.ts b/src/main.ts index be2aaa0..3380553 100644 --- a/src/main.ts +++ b/src/main.ts @@ -91,6 +91,30 @@ program.command('cascade').description('Strategy cascade: find simplest working console.log(renderCascadeResult(result)); }); +program.command('doctor') + .description('Diagnose Playwright MCP Bridge, token consistency, and Chrome remote debugging') + .option('--fix', 'Apply suggested fixes to shell rc and detected MCP configs', false) + .option('-y, --yes', 'Skip confirmation prompts when applying fixes', false) + .option('--token ', 'Override token to write instead of auto-detecting') + .option('--shell-rc ', 'Shell startup file to update') + .option('--mcp-config ', 'Comma-separated MCP config paths to scan/update') + .action(async (opts) => { + const { runBrowserDoctor, renderBrowserDoctorReport, applyBrowserDoctorFix } = await import('./doctor.js'); + const configPaths = opts.mcpConfig ? String(opts.mcpConfig).split(',').map((s: string) => s.trim()).filter(Boolean) : undefined; + const report = await runBrowserDoctor({ token: opts.token, shellRc: opts.shellRc, configPaths }); + console.log(renderBrowserDoctorReport(report)); + if (opts.fix) { + const written = await applyBrowserDoctorFix(report, { fix: true, yes: opts.yes, token: opts.token, shellRc: opts.shellRc, configPaths }); + console.log(); + if (written.length > 0) { + console.log(chalk.green('Updated files:')); + for (const filePath of written) console.log(`- ${filePath}`); + } else { + console.log(chalk.yellow('No files were changed.')); + } + } + }); + // ── Dynamic site commands ────────────────────────────────────────────────── const registry = getRegistry();